package acquire import ( "context" "errors" "sort" "strings" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/indexer" "git.fabledsword.com/bvandeusen/minstrel/internal/release" ) // One search for one want, and what could be done about each result (M502 // #5521), ported from Fabled Librarian's internal/acquire. internal/release // says whether a name is the album and what quality it is; this package // asks in the right order and says why in words a person can act on. // Verdict is what can be done about one result. type Verdict string const ( // Grab: the album, in a quality the profile takes. Grab Verdict = "grab" // Review: maybe the album; a person decides. Review Verdict = "review" // Refused: the album, in a quality the profile does not take. The only // verdict that names a setting. Refused Verdict = "refused" // Rejected: not the album. Rejected Verdict = "rejected" // Blocked: a release set aside, by a person or by its files. Blocked Verdict = "blocked" ) // order sorts verdicts: what can be taken first, what was given up on last. var order = map[Verdict]int{Grab: 0, Review: 1, Refused: 2, Rejected: 3, Blocked: 4} // Reason kinds: a hard reason is a fact about the bytes, a soft one about the // moment or a setting. const ( Hard = "hard" Soft = "soft" ) // Reason codes beyond internal/release's vetoes. const ( CodeBlocked = "blocked" CodeProfile = "profile" CodeNoSeeders = "no_seeders" CodeDoubt = "doubt" // CodeNotBetter: the album, but no better than the copy the library // holds, for a want that is an upgrade (#5524). CodeNotBetter = "not_better" ) // Reason is one thing a verdict rests on. type Reason struct { Kind string `json:"kind"` Code string `json:"code"` Text string `json:"text"` } // Want is the album searched for: its wants row and how the catalogue // describes it. type Want struct { ID pgtype.UUID release.Want // Floor is the tier of the copy the library holds, for an album wanted // for a better one (#5524): a release must rank above it. "" for an // album not held, and for a video rip, which any clean copy beats. Floor release.Tier // Held says the floor in words: "MP3, about 192 kbps". Held string } // Candidate is one result with everything decided about it. type Candidate struct { release.Release // Source is the indexer's own result. It never leaves the server: its // DownloadURL can carry the indexer's key. Source indexer.Result InfoHash string Indexer string SizeBytes int64 Seeders int SwarmKnown bool Category int FilesCount int // Files is the torrent's file list once its .torrent has been read. Files []release.File FoldedFrom []string Tier release.Tier YearAgrees bool Verdict Verdict Reason string Reasons []Reason } // Key is how a candidate is named across a search and a grab: its infohash, // or its name when the source gave none. func Key(c Candidate) string { if c.InfoHash != "" { return c.InfoHash } return c.Name } // Result is one search. type Result struct { Query string // Unconfigured: no search source is set up. Unconfigured bool // Error is a search that failed outright. Error string // Narrowed names sources that did not answer on a search that otherwise // did. Narrowed []Narrowing Considered int Folded int // Looked is how many .torrent files were read for their file lists. Looked int Candidates []Candidate // KeepError: the candidates could not be stored. KeepError string } // Grabbable is what may be taken, best first. func (r Result) Grabbable() []Candidate { var out []Candidate for _, c := range r.Candidates { if c.Verdict == Grab { out = append(out, c) } } return out } // ForAPerson says whether anything found is worth a person's look: a // release that may be the album, whatever its quality. func (r Result) ForAPerson() bool { for _, c := range r.Candidates { if c.Verdict == Grab || c.Verdict == Review || c.Verdict == Refused { return true } } return false } // Service searches and grabs. type Service struct { // Search is nil when no source is configured. Search Searcher // Grabber is nil when no download client is configured. Grabber *Downloader Profile release.Profile // Memory stores candidates, grabs and blocks. Nil keeps nothing. Memory *Memory } // Query is the text sent to the indexers: the artist and the title, without // a trailing bracketed part the release names usually leave off. func Query(w release.Want) string { title := w.Title if i := strings.IndexAny(title, "(["); i > 0 { title = title[:i] } return strings.Join(strings.Fields(strings.TrimSpace(w.Artist)+" "+strings.TrimSpace(title)), " ") } // For searches for one want and judges everything that comes back. It never // returns an error: a search that could not be made is a Result saying so. func (s *Service) For(ctx context.Context, want Want) Result { r := Result{Query: Query(want.Want)} if s == nil || s.Search == nil { r.Unconfigured = true return r } found, err := s.Search.Search(ctx, indexer.Query{Text: r.Query, Categories: indexer.MusicCategories}) // A partial search arrives as results and an error together. if narrowed, answered := narrowings(err); narrowed != nil && answered { r.Narrowed, err = narrowed, nil } switch { case errors.Is(err, indexer.ErrUnconfigured): r.Unconfigured = true return r case err != nil: r.Error = indexer.FirstLine(err.Error()) return r } r.Considered = len(found) cands, folded := fold(found) r.Folded = folded blocked := s.Memory.blocked(ctx) for i := range cands { decide(&cands[i], want, s.Profile, blocked) } sortCandidates(cands) for _, i := range s.peek(ctx, cands) { decide(&cands[i], want, s.Profile, blocked) r.Looked++ } sortCandidates(cands) r.Candidates = cands if s.Memory != nil && want.ID.Valid { if err := s.Memory.Keep(ctx, want.ID, cands); err != nil { r.KeepError = indexer.FirstLine(err.Error()) } } return r } // decide works out one candidate's verdict, in order: given up on, then // whether it is the album, then whether the profile takes its quality, then // whether anything leaves it to a person. A quality refusal said about the // wrong album would be noise, so identity comes first. func decide(c *Candidate, want Want, profile release.Profile, blocked map[string]string) { w := want.Want c.Verdict, c.Reason, c.Reasons = "", "", nil if why, ok := blocked[c.InfoHash]; ok && c.InfoHash != "" { set(c, Blocked, Hard, CodeBlocked, why) return } m := release.Judge(w, c.Release) c.YearAgrees = m.YearAgrees if m.Veto != "" { set(c, Rejected, Hard, m.Veto, m.Reason) return } doubt := m.Doubt if len(c.Files) > 0 { if in := release.Inspect(c.Files); in.Format != "" { c.Format = in.Format } veto, reason, fileDoubt := release.JudgeFiles(w, c.Files) if veto != "" { set(c, Rejected, Hard, veto, reason) return } if doubt == "" { doubt = fileDoubt } } c.Tier = release.TierOf(c.Release, c.Category) if !profile.Allows(c.Tier) { set(c, Refused, Soft, CodeProfile, "Your quality settings don’t take "+strings.ToLower(release.Label(c.Tier))+".") return } if want.Floor != "" && release.Rank(c.Tier) <= release.Rank(want.Floor) { held := want.Held if held == "" { held = strings.ToLower(release.Label(want.Floor)) } set(c, Refused, Soft, CodeNotBetter, "No better than the copy you have ("+held+").") return } if doubt != "" { set(c, Review, Soft, CodeDoubt, doubt) return } if c.Seeders == 0 { set(c, Review, Soft, CodeNoSeeders, "Nobody is sharing it right now.") return } c.Verdict = Grab c.Reason = "The album, " + strings.ToLower(release.Label(c.Tier)) + "." if d := release.Describe(c.Release); d != "" { c.Reason = "The album, " + d + "." } } func set(c *Candidate, v Verdict, kind, code, text string) { c.Verdict, c.Reason = v, text c.Reasons = append(c.Reasons, Reason{Kind: kind, Code: code, Text: text}) } // sortCandidates puts the best first, stably so the same search proposes the // same grab twice. func sortCandidates(cands []Candidate) { sort.SliceStable(cands, func(a, b int) bool { return better(cands[a], cands[b]) }) } // better says whether x ranks above y: verdict, then quality, then how many // share it, then whether its year agrees. func better(x, y Candidate) bool { if order[x.Verdict] != order[y.Verdict] { return order[x.Verdict] < order[y.Verdict] } if rx, ry := release.Rank(x.Tier), release.Rank(y.Tier); rx != ry { return rx > ry } if x.Seeders != y.Seeders { return x.Seeders > y.Seeders } return x.YearAgrees && !y.YearAgrees } // fold merges results that are the same torrent, by infohash. Prowlarr // aggregates, so one release arrives once per tracker that lists it. A // result without a hash is never folded: an absent hash is not a shared one. func fold(found []indexer.Result) ([]Candidate, int) { out := make([]Candidate, 0, len(found)) at := make(map[string]int, len(found)) folded := 0 for _, f := range found { if strings.TrimSpace(f.Title) == "" { continue } hash := indexer.NormalizeHash(f.InfoHash) if hash == "" && f.Magnet != "" { hash = indexer.MagnetHash(f.Magnet) } if hash != "" { if i, seen := at[hash]; seen { folded++ if f.Indexer != "" { out[i].FoldedFrom = append(out[i].FoldedFrom, f.Indexer) } // A measured swarm beats a reported one; among like, the // highest count. switch { case f.SwarmKnown && !out[i].SwarmKnown: out[i].Seeders, out[i].SwarmKnown = f.Seeders, true case f.SwarmKnown == out[i].SwarmKnown && f.Seeders > out[i].Seeders: out[i].Seeders = f.Seeders } if f.FilesCount > 0 && out[i].FilesCount == 0 { out[i].FilesCount = f.FilesCount } continue } at[hash] = len(out) } out = append(out, Candidate{ Release: release.Parse(f.Title), Source: f, InfoHash: hash, Indexer: f.Indexer, SizeBytes: f.SizeBytes, Seeders: f.Seeders, SwarmKnown: f.SwarmKnown, Category: f.Category, FilesCount: f.FilesCount, }) } return out, folded } // maxPeeks is how many .torrent files one search reads. Few: each goes // through Prowlarr to a tracker, and Prowlarr answers 429 to a burst. const maxPeeks = 3 // peek reads the .torrent of candidates that are the album but whose name // states no format, so their files can say what they are; it returns which // now carry a file list. A failed fetch leaves the candidate as it was. func (s *Service) peek(ctx context.Context, cands []Candidate) []int { f, ok := s.Search.(Fetcher) if !ok { return nil } var looked []int for i := range cands { if len(looked) >= maxPeeks || ctx.Err() != nil { break } c := &cands[i] if c.Tier != release.TierUnknown || (c.Verdict != Grab && c.Verdict != Refused) || c.Files != nil || c.Source.DownloadURL == "" || c.Source.Magnet != "" { continue } body, err := f.Download(ctx, c.Source) if err != nil { continue } meta, err := release.ParseTorrent(body) if err != nil { continue } c.Files = meta.Files if c.InfoHash == "" { c.InfoHash = meta.InfoHash } if c.FilesCount == 0 { c.FilesCount = len(meta.Files) } looked = append(looked, i) } return looked } // pick finds a candidate by key: the infohash first, the exact name for a // result that has none. func pick(cands []Candidate, key string) *Candidate { key = strings.TrimSpace(key) if key == "" { return nil } hash := indexer.NormalizeHash(key) for i := range cands { if cands[i].InfoHash != "" && cands[i].InfoHash == hash { return &cands[i] } } for i := range cands { if cands[i].InfoHash == "" && cands[i].Name == key { return &cands[i] } } return nil }