release / govulncheck (push) Successful in 21s
release / go (push) Failing after 33s
release / web (push) Successful in 56s
release / integration (push) Failing after 4m48s
release / android (push) Successful in 5m6s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- internal/release: reads a release name (format, bitrate, bit depth and sample rate, source, year, video, discography) and judges it against the album wanted: the artist and title must be in it; video rips, discographies, other versions (drumless, instrumental, karaoke, remix, live unless the album is live…) and singles or EPs for an album are refused. Quality tiers on Lidarr's ladder; a .torrent's file list is read for format, video files and a track count short of the album. - internal/acquire: fan-out across every search source with a narrowed search kept, a process-wide pacer per source, every candidate judged and kept with its reasons, and the grab: one live grab per want, tie-broken (a person's grab beats the schedule's; a better release replaces a worse one unless it is nearly done). Each download lands in its own folder under the client's downloads folder. - internal/wanted: the search schedule, every ten minutes, never-searched first, then requests, missing files and followed artists, taking the best release when auto-grab is on (the default). - Migration 0082: acquisition_settings (auto-grab, interval, allowed qualities seeded from Lidarr's "Any"), want search columns, want_candidates, grabs, release_blocks. - Web: the Wanted page shows each want's last search, a Search button and every release found with its verdict (Grab, Grab anyway, Never this one, Allow again). Integrations gains "Searching and grabbing". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
391 lines
11 KiB
Go
391 lines
11 KiB
Go
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"
|
||
)
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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.Want, s.Profile, blocked)
|
||
}
|
||
sortCandidates(cands)
|
||
for _, i := range s.peek(ctx, cands) {
|
||
decide(&cands[i], want.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, w release.Want, profile release.Profile, blocked map[string]string) {
|
||
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 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
|
||
}
|