Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.
Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
today rather than a milestone that would change it.
Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
11 KiB
Go
339 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. One live grab per want: a second is tie-broken against
|
|
// the download already running.
|
|
|
|
// Tag goes on every torrent Minstrel adds, so they can be told apart from
|
|
// anything else 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. 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(), "-")
|
|
}
|