Files
minstrel/internal/acquire/search.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
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>
2026-10-23 12:15:05 -04:00

409 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
// 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.
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: 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
}