diff options
| author | Xe Iaso <me@xeiaso.net> | 2023-07-26 11:57:32 -0400 |
|---|---|---|
| committer | Xe Iaso <me@xeiaso.net> | 2023-07-26 11:57:32 -0400 |
| commit | 1640e2c5b0b070cb2118aade61084e092947f85f (patch) | |
| tree | 007d80a5b0fa429b388970d67c8780932522f63b /cmd | |
| parent | 4b1553f221474f22a7176c1c9dab5e9be00ef47d (diff) | |
| download | x-1640e2c5b0b070cb2118aade61084e092947f85f.tar.xz x-1640e2c5b0b070cb2118aade61084e092947f85f.zip | |
cmd: add new command sanguisuga
Signed-off-by: Xe Iaso <me@xeiaso.net>
Diffstat (limited to 'cmd')
| -rw-r--r-- | cmd/sanguisuga/.gitignore | 2 | ||||
| -rw-r--r-- | cmd/sanguisuga/config.default.ts | 55 | ||||
| -rw-r--r-- | cmd/sanguisuga/config.go | 32 | ||||
| -rw-r--r-- | cmd/sanguisuga/main.go | 298 |
4 files changed, 387 insertions, 0 deletions
diff --git a/cmd/sanguisuga/.gitignore b/cmd/sanguisuga/.gitignore new file mode 100644 index 0000000..5841fb6 --- /dev/null +++ b/cmd/sanguisuga/.gitignore @@ -0,0 +1,2 @@ +config.ts +data.json diff --git a/cmd/sanguisuga/config.default.ts b/cmd/sanguisuga/config.default.ts new file mode 100644 index 0000000..12b8632 --- /dev/null +++ b/cmd/sanguisuga/config.default.ts @@ -0,0 +1,55 @@ +export type IRC = { + server: string; + password: string; + channel: string; + nick: string; + user: string; + real: string; +}; + +export type Show = { + title: string; + diskPath: string; + quality: string; +}; + +export type Transmission = { + host: string; + user: string; + password: string; + https: bool; + rpcURI: string; +}; + +export type Config = { + irc: IRC; + transmission: Transmission; + shows: Show[]; + rssKey: string; +}; + +export default { + irc: { + server: "", + password: "", + channel: "", + nick: "", + user: "", + real: "" + }, + transmission: { + host: "", + user: "", + password: "", + https: true, + rpcURI: "/transmission/rpc" + }, + shows: [ + { + title: "Show Name", + diskPath: "/data/TV/Show Name", + quality: "1080p" + } + ], + rssKey: "", +} satisfies Config; diff --git a/cmd/sanguisuga/config.go b/cmd/sanguisuga/config.go new file mode 100644 index 0000000..589501d --- /dev/null +++ b/cmd/sanguisuga/config.go @@ -0,0 +1,32 @@ +package main + +type IRC struct { + Server string `json:"server"` + Password string `json:"password"` + Channel string `json:"channel"` + Regex string `json:"regex"` + Nick string `json:"nick"` + User string `json:"user"` + Real string `json:"real"` +} + +type Show struct { + Title string `json:"title"` + DiskPath string `json:"diskPath"` + Quality string `json:"quality"` +} + +type Transmission struct { + Host string `json:"host"` + User string `json:"user"` + Password string `json:"password"` + HTTPS bool `json:"https"` + RPCURI string `json:"rpcURI"` +} + +type Config struct { + IRC IRC `json:"irc"` + Transmission Transmission `json:"transmission"` + Shows []Show `json:"shows"` + RSSKey string `json:"rssKey"` +} diff --git a/cmd/sanguisuga/main.go b/cmd/sanguisuga/main.go new file mode 100644 index 0000000..e6f64f3 --- /dev/null +++ b/cmd/sanguisuga/main.go @@ -0,0 +1,298 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/hekmon/transmissionrpc/v2" + irc "github.com/thoj/go-ircevent" + "go.jetpack.io/tyson" + "golang.org/x/exp/slog" + "tailscale.com/hostinfo" + "tailscale.com/jsondb" + "within.website/x/internal" +) + +var ( + dbLoc = flag.String("db-loc", "./data.json", "path to data file") + tysonConfig = flag.String("tyson-config", "./config.ts", "path to configuration secrets (TySON)") + slogLevel = flag.String("slog-level", "INFO", "log level") + + annRegex = regexp.MustCompile(`^New Torrent Announcement: <([^>]*)>\s+Name:'(.*)' uploaded by '.*' ?(freeleech)?\s+-\s+https://\w+.\w+.\w+./\w+./([0-9]+)$`) + showName = regexp.MustCompile(`^(.*)\s+(S[0-9]+E[0-9]+)\s+([0-9]+p)\s+(\w+)\s+(.*)$`) +) + +type ShowMeta struct { + Name string + SeasonEpisode *SeasonEpisode + Quality string + Kind string + Group string +} + +func (sm ShowMeta) StateKey() string { + return fmt.Sprintf("%s %s", sm.Name, sm.SeasonEpisode) +} + +func ParseShowMeta(input string) (*ShowMeta, error) { + match := showName.FindStringSubmatch(input) + + if match == nil { + return nil, fmt.Errorf("invalid input for TV show name: %q", input) + } + + result := ShowMeta{ + Name: strings.TrimSpace(match[1]), + Quality: match[3], + Kind: match[4], + Group: match[5], + } + + se, err := ParseSeasonEpisode(match[2]) + if err != nil { + return nil, err + } + + result.SeasonEpisode = se + + return &result, nil +} + +type SeasonEpisode struct { + Season string + Episode string +} + +func (se SeasonEpisode) GetFormattedSeason() string { + return "Season " + se.Season +} + +func (se *SeasonEpisode) String() string { + return "S" + se.Season + "E" + se.Episode +} + +func ParseSeasonEpisode(input string) (*SeasonEpisode, error) { + re := regexp.MustCompile(`S([0-9]+)E([0-9]+)`) + match := re.FindStringSubmatch(input) + + if match == nil { + return nil, fmt.Errorf("invalid input for SeasonEpisode: %q", input) + } + + season := match[1] + episode := match[2] + se := &SeasonEpisode{ + Season: season, + Episode: episode, + } + + return se, nil +} + +func ConvertURL(torrentID, rssKey, name string) string { + name = strings.ReplaceAll(name, " ", ".") + ".torrent" + return fmt.Sprintf("https://torrentleech.org/rss/download/%s/%s/%s", torrentID, rssKey, name) +} + +type TorrentAnnouncement struct { + Category string + Name string + Freeleech bool + TorrentID string +} + +func ParseTorrentAnnouncement(input string) (*TorrentAnnouncement, error) { + match := annRegex.FindStringSubmatch(input) + + if match == nil { + return nil, fmt.Errorf("invalid torrent announcement format") + } + + torrent := &TorrentAnnouncement{ + Category: match[1], + Name: strings.TrimSpace(match[2]), + Freeleech: match[3] != "", + TorrentID: match[4], + } + + return torrent, nil +} + +func main() { + internal.HandleStartup() + hostinfo.SetApp("within.website/x/cmd/sanguisuga") + + var programLevel slog.Level + if err := (&programLevel).UnmarshalText([]byte(*slogLevel)); err != nil { + fmt.Fprintf(os.Stderr, "invalid log level %s: %v, using info\n", *slogLevel, err) + programLevel = slog.LevelInfo + } + + h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: programLevel, + }) + slog.SetDefault(slog.New(h)) + + var c Config + if err := tyson.Unmarshal(*tysonConfig, &c); err != nil { + slog.Error("can't unmarshal config", "err", err) + os.Exit(1) + } + + db, err := jsondb.Open[State](*dbLoc) + if err != nil { + slog.Error("can't set up database", "err", err) + os.Exit(1) + } + if db.Data == nil { + db.Data = &State{ + Seen: map[string]TorrentAnnouncement{}, + } + } + if err := db.Save(); err != nil { + slog.Error("can't ping database", "err", err) + os.Exit(1) + } + + tm, err := transmissionrpc.New(c.Transmission.Host, c.Transmission.User, c.Transmission.Password, &transmissionrpc.AdvancedConfig{ + Port: 443, + HTTPS: c.Transmission.HTTPS, + RPCURI: c.Transmission.RPCURI, + }) + if err != nil { + slog.Error("can't connect to transmission", "err", err) + os.Exit(1) + } + _ = tm + + portOpen, err := tm.PortTest(context.Background()) + if err != nil { + slog.Error("can't test if port is open", "err", err) + os.Exit(1) + } + + slog.Info("port status", "open", portOpen) + + s := &Sanguisuga{ + Config: c, + tc: tm, + db: db, + } + + ircCli := irc.IRC(c.IRC.Nick, c.IRC.User) + ircCli.Password = c.IRC.Password + ircCli.RealName = c.IRC.Real + ircCli.AddCallback("PRIVMSG", s.HandleIRCMessage) + ircCli.AddCallback("001", func(ev *irc.Event) { + ircCli.Join(c.IRC.Channel) + }) + ircCli.Log = slog.NewLogLogger(h.WithAttrs([]slog.Attr{slog.String("from", "ircevent")}), slog.LevelInfo) + ircCli.Timeout = 5 * time.Second + + if err := ircCli.Connect(c.IRC.Server); err != nil { + slog.Error("can't connect to IRC server", "server", c.IRC.Server, "err", err) + os.Exit(1) + } + + ircCli.Loop() +} + +type Sanguisuga struct { + Config Config + tc *transmissionrpc.Client + db *jsondb.DB[State] +} + +func (s *Sanguisuga) DelayedStartTorrent(tid int64) { + s.tc.TorrentStopIDs(context.Background(), []int64{tid}) + time.Sleep(5 * time.Second) // delay a bit + s.tc.TorrentStartNowIDs(context.Background(), []int64{tid}) +} + +type State struct { + // Name + " " + SeasonEpisode -> TorrentAnnouncement + Seen map[string]TorrentAnnouncement +} + +func (s *Sanguisuga) HandleIRCMessage(ev *irc.Event) { + // check if in channel + if ev.Code != "PRIVMSG" { + return + } + + if ev.Arguments[0] != s.Config.IRC.Channel { + return + } + + ta, err := ParseTorrentAnnouncement(ev.MessageWithoutFormat()) + if err != nil { + slog.Debug("can't parse torrent announcment", "err", err, "msg", ev.MessageWithoutFormat()) + return + } + + slog.Debug("found torrent announcment", "category", ta.Category, "freeleech", ta.Freeleech, "name", ta.Name) + + if ta.Category == "TV :: Episodes HD" { + sm, err := ParseShowMeta(ta.Name) + if err != nil { + slog.Debug("can't parse ShowMeta", "err", err, "name", ta.Name) + return + } + id := sm.SeasonEpisode.String() + slog.Debug("found ShowMeta", "title", sm.Name, "id", id, "quality", sm.Quality, "group", sm.Group) + + for _, show := range s.Config.Shows { + if s.db.Data == nil { + s.db.Data = &State{ + Seen: map[string]TorrentAnnouncement{}, + } + } + if _, found := s.db.Data.Seen[sm.StateKey()]; found { + slog.Info("already snatched", "title", sm.Name, "id", id) + return + } + + if show.Title != sm.Name { + slog.Debug("wrong name") + continue + } + + if show.Quality != sm.Quality { + slog.Debug("wrong quality") + continue + } + + torrentURL := ConvertURL(ta.TorrentID, s.Config.RSSKey, ta.Name) + + slog.Debug("found url", "url", torrentURL) + downloadDir := filepath.Join(show.DiskPath, sm.SeasonEpisode.GetFormattedSeason()) + + t, err := s.tc.TorrentAdd(context.Background(), transmissionrpc.TorrentAddPayload{ + DownloadDir: &downloadDir, + Filename: aws.String(torrentURL), + Paused: aws.Bool(false), + }) + if err != nil { + slog.Error("error adding torrent", "err", err, "torrentID", ta.TorrentID) + return + } + + go s.DelayedStartTorrent(*t.ID) + + slog.Info("added torrent", "title", sm.Name, "id", id, "path", downloadDir) + + s.db.Data.Seen[sm.StateKey()] = *ta + if err := s.db.Save(); err != nil { + slog.Error("error saving state", "err", err) + } + } + } +} |
