Files
bvandeusenandClaude Opus 5.5 484a81f779
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m34s
release / integration (push) Successful in 5m5s
release / android (push) Successful in 5m7s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 22s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: a release its download client will not let Minstrel follow is held back, not taken every pass (#6176)
The Black Keys' Ohio Players was grabbed 40 times in a day. FabledExchange
already held the torrent under another category from before Minstrel took
the downloads over: the add answered "duplicate", which counted as added,
and Exchange's listing, filtered to Minstrel's category, never showed it.
Half an hour later the grab ended gone, and the next pass took it again.

- A duplicate add that the client does not list for Minstrel is not a
  grab: the release is held back for a day with the client's own reason
  (already downloading X (category)), and the search moves on. Forced,
  it is refused with that reason.
- A release that ends gone a second time within a week is held back for
  a day, whatever the cause.
- release_blocks gains the reason 'gone' (migration 0098), shown as
  "Held back: its download client lost track of it".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:31:57 -04:00

340 lines
11 KiB
Go

package acquire
import (
"context"
"errors"
"path"
"strings"
"sync"
"time"
"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)
err := s.Grabber.Client.AddTorrent(ctx, add)
if errors.Is(err, downloader.ErrDuplicate) && s.heldElsewhere(ctx, hash) {
why := "Already in the download client outside Minstrel's category, where Minstrel cannot follow it (" +
strings.TrimPrefix(indexer.FirstLine(err.Error()), downloader.ErrDuplicate.Error()+": ") + ")"
if force {
out.Error = why + "."
return out
}
set(chosen, Blocked, Hard, CodeBlocked, why+".")
if s.Memory != nil && hash != "" {
_ = s.Memory.Block(ctx, dbq.BlockReleaseParams{
InfoHash: hash, Name: chosen.Name, Reason: BlockGone, Detail: why + ".", WantID: want.ID,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(GoneBlock), Valid: true},
})
}
out.Refused = true
return out
}
if 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
}
// heldElsewhere reports whether a release the client says it already holds is
// one it does not list for Minstrel: held under another category, from before
// Minstrel took the downloads over (#6176). Recorded as a grab, it would read
// as gone half an hour later and be taken again, every pass. A client that
// cannot answer now is given the benefit of the doubt, as the add was.
func (s *Service) heldElsewhere(ctx context.Context, hash string) bool {
if hash == "" {
return false
}
listed, err := s.Grabber.Client.Torrents(ctx, indexer.NormalizeHash(hash))
return err == nil && len(listed) == 0
}
// 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(), "-")
}