package acquire import ( "context" "errors" "path" "strings" "sync" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/downloader" "git.fabledsword.com/bvandeusen/minstrel/internal/indexer" "git.fabledsword.com/bvandeusen/minstrel/internal/release" ) // Taking a release (M502 #5521), ported from Fabled Librarian's grab.go and // rivals.go. One live grab per want: a second is tie-broken against the // download already running (FL #4470). // Tag goes on every torrent Minstrel adds, so the operator can tell them // from their own in the client. const Tag = "minstrel" // MissingClient is Taken.Missing when no download client is set up. const MissingClient = "download_client" // nearlyDone is how far along a running download stays whatever would // replace it. const nearlyDone = 0.9 // Downloader is the grab half of a Service. type Downloader struct { Client downloader.Driver // Code is the client's acquisition_services row. Code string // Landing is the client's downloads folder as Minstrel sees it; each // grab lands in a folder of its own under it. Landing string // Fetch gets a result's .torrent from the source that offered it. Fetch Fetcher } // Origin says who took a release. type Origin struct { By string // OriginSchedule or OriginOperator User pgtype.UUID } // Contest is how a grab fared against the download already running. type Contest struct { // Kept: the running one stays and this grab was not made. Replaced: // this grab took its place. Kept, Replaced bool Reason string Rival dbq.Grab } // Taken is what came of a grab. type Taken struct { Chosen *Candidate // Added is the only field that means a download started. Added bool SavePath string Grab dbq.Grab // Gone: the search no longer offers the release. Gone bool // Refused: not taken; the reason is on Chosen. Refused bool // Unconfigured: Missing names what is not set up ("" for search). Unconfigured bool Missing string Contest *Contest Error string } // Take searches again and grabs the candidate named by key: a person's // grab. Searching again keeps the indexer's download link (which can carry // its key) on the server, and finds a release gone since it was shown. // force takes what the verdict would not. func (s *Service) Take(ctx context.Context, want Want, key string, force bool, origin Origin) Taken { if s.Grabber == nil { return Taken{Unconfigured: true, Missing: MissingClient} } found := s.For(ctx, want) switch { case found.Unconfigured: return Taken{Unconfigured: true} case found.Error != "": return Taken{Error: found.Error} } chosen := pick(found.Candidates, key) if chosen == nil { return Taken{Gone: true} } return s.Grab(ctx, want, chosen, found.Candidates, force, origin) } // Grab hands a judged candidate to the download client. The schedule calls // it with the candidate it has just judged; Take calls it after searching // again. func (s *Service) Grab(ctx context.Context, want Want, chosen *Candidate, all []Candidate, force bool, origin Origin) Taken { out := Taken{Chosen: chosen} if s.Grabber == nil || s.Grabber.Client == nil { out.Unconfigured, out.Missing = true, MissingClient return out } if chosen.Verdict == Blocked || (chosen.Verdict != Grab && !force) { out.Refused = true return out } unlock := lockWant(want.ID) defer unlock() contest := s.contest(ctx, want, chosen, all, origin) if contest != nil && contest.Kept { out.Contest = contest return out } add := downloader.Add{Magnet: chosen.Source.Magnet, Tags: []string{Tag}} hash := chosen.InfoHash if add.Magnet == "" { if s.Grabber.Fetch == nil { out.Error = "This release is a .torrent link, and nothing is set up to fetch it." return out } body, err := s.Grabber.Fetch.Download(ctx, chosen.Source) if magnet, ok := indexer.AsMagnet(err); ok { add.Magnet, err = magnet, nil } if err != nil { out.Error = indexer.FirstLine(err.Error()) return out } if add.Magnet == "" { add.Torrent = body if meta, err := release.ParseTorrent(body); err == nil { if hash == "" { hash = meta.InfoHash } if refused := s.judgeFiles(ctx, want, chosen, hash, meta.Files, force); refused { out.Refused = true return out } } } } if hash == "" && add.Magnet != "" { hash = indexer.MagnetHash(add.Magnet) } add.SavePath = landingFor(s.Grabber.Landing, hash, chosen.Name) if err := s.Grabber.Client.AddTorrent(ctx, add); err != nil && !errors.Is(err, downloader.ErrDuplicate) { if errors.Is(err, downloader.ErrUnconfigured) { out.Unconfigured, out.Missing = true, MissingClient } else { out.Error = indexer.FirstLine(err.Error()) } return out } out.Added, out.SavePath = true, add.SavePath if s.Memory == nil { return out } var replaced pgtype.UUID why := "" if contest != nil && contest.Replaced { out.Contest = contest replaced, why = contest.Rival.ID, "Replaced by "+chosen.Name+": "+contest.Reason+"." } g, err := s.Memory.Grabbed(ctx, dbq.InsertGrabParams{ WantID: want.ID, InfoHash: hash, Name: chosen.Name, Key: Key(*chosen), Indexer: chosen.Indexer, Source: chosen.Source.Source, Client: s.Grabber.Code, SavePath: add.SavePath, Quality: string(chosen.Tier), Origin: origin.By, Forced: force, GrabbedBy: origin.User, }, replaced, why) if err != nil { out.Error = "The download started, but could not be recorded: " + indexer.FirstLine(err.Error()) return out } out.Grab = g // Removed only after the new download was added and recorded, so a // failure never leaves the want with nothing running. if replaced.Valid && contest.Rival.InfoHash != "" { if err := s.Grabber.Client.Delete(ctx, true, contest.Rival.InfoHash); err != nil { contest.Reason += ". The download it replaced could not be removed from the client: " + indexer.FirstLine(err.Error()) } } return out } // judgeFiles judges a chosen release again on its .torrent's file list, and // sets it aside when the files say it is not the album, so no later search // offers it again. force still takes it. func (s *Service) judgeFiles(ctx context.Context, want Want, chosen *Candidate, hash string, files []release.File, force bool) bool { chosen.Files = files if chosen.FilesCount == 0 { chosen.FilesCount = len(files) } veto, reason, _ := release.JudgeFiles(want.Want, files) if veto == "" || force { return false } set(chosen, Rejected, Hard, veto, reason) if s.Memory != nil && hash != "" { _ = s.Memory.Block(ctx, dbq.BlockReleaseParams{ InfoHash: hash, Name: chosen.Name, Reason: BlockFiles, Detail: reason, WantID: want.ID, }) } return true } var wantLocks sync.Map // want id → *sync.Mutex // lockWant settles two grabs for one want one after the other. The // one-live-grab index is the backstop for anything outside this process. func lockWant(id pgtype.UUID) func() { if !id.Valid { return func() {} } m, _ := wantLocks.LoadOrStore(id.Bytes, &sync.Mutex{}) mu := m.(*sync.Mutex) mu.Lock() return mu.Unlock } // contest settles a grab against the download running for its want: a // person's grab beats the schedule's; between two of a kind the better // candidate wins; a tie, a download nearly done, or one that cannot be // removed stays. Nil when nothing is running. func (s *Service) contest(ctx context.Context, want Want, chosen *Candidate, all []Candidate, origin Origin) *Contest { if s.Memory == nil || !want.ID.Valid { return nil } running, ok, err := s.Memory.LiveGrab(ctx, want.ID) if err != nil || !ok { return nil } c := &Contest{Rival: running} if running.InfoHash != "" && running.InfoHash == chosen.InfoHash { c.Kept, c.Reason = true, "That release is already downloading for this album." return c } switch { case origin.By == OriginOperator && running.Origin == OriginSchedule: c.Reason = "you chose it over the one the schedule started" case origin.By == OriginSchedule && running.Origin == OriginOperator: c.Kept, c.Reason = true, "A download you started for this album is already running." return c default: var theirs *Candidate for i := range all { if (running.InfoHash != "" && all[i].InfoHash == running.InfoHash) || Key(all[i]) == running.Key { theirs = &all[i] break } } if theirs == nil || !better(*chosen, *theirs) { c.Kept, c.Reason = true, "A download for this album is already running, and this release is no better." return c } c.Reason = "it ranks above the one that was running" } if running.InfoHash == "" { c.Kept, c.Reason = true, "A download for this album is already running, and it cannot be identified to replace it." return c } if ts, err := s.Grabber.Client.Torrents(ctx, running.InfoHash); err == nil && len(ts) > 0 && ts[0].Progress >= nearlyDone { c.Kept, c.Reason = true, "A download for this album is already running and nearly done." return c } c.Replaced = true return c } // landingFor is the folder a download lands in: one per torrent, named by // its infohash, so two grabs never unpack into the same place. func landingFor(root, hash, name string) string { dir := hash if dir == "" { dir = slug(name) } if dir == "" { dir = "unnamed" } return path.Join(root, dir) } // slug keeps a folder name to characters every filesystem in the chain // takes. func slug(s string) string { var b strings.Builder for _, r := range strings.ToLower(strings.TrimSpace(s)) { if b.Len() >= 80 { break } switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) case r == '-', r == '_', r == ' ', r == '.': b.WriteRune('-') } } return strings.Trim(b.String(), "-") }