release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 27s
release / Verify release artifacts (tag releases only) (push) Skipped
- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's cutoff, note #5527; "" is off), an "Upgrade until" row under Searching. - The wanted worker judges held albums hourly: tier per format from size per second (the scanner records no bitrate), the album's worst. Below the cutoff it is wanted for quality; a video rip among its tracks always. - The schedule searches upgrades weekly, after open wants; a release no better than the copy held is refused ("not_better"). - The import matches each file to the track it would replace (recording, then disc/track) and replaces it when it is a rip or the file is a better tier: over the same path in one rename, or filed beside it and the old track folded in (library.ReplaceTrack: file first, then foldTrackInto). Nothing better: the release is set aside. - Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a clean copy was looked for instead of Lidarr's search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
362 lines
12 KiB
Go
362 lines
12 KiB
Go
package wanted
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/acquire"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
|
|
)
|
|
|
|
// Searching for wants on a schedule (M502 #5521), ported from Fabled
|
|
// Librarian's internal/wanted. The schedule decides which wants and when;
|
|
// internal/acquire judges what a search finds and grabs.
|
|
|
|
// Search outcomes, matching wants_search_outcome_check.
|
|
const (
|
|
OutcomeGrabbed = "grabbed"
|
|
OutcomeWaiting = "waiting"
|
|
OutcomeNothing = "nothing"
|
|
OutcomeError = "error"
|
|
)
|
|
|
|
const (
|
|
// searchTick is how often the schedule looks for wants due; the
|
|
// operator's setting is how long a want waits between searches.
|
|
searchTick = 10 * time.Minute
|
|
// searchWindow is how long one pass searches: most of a tick, so a pass
|
|
// ends before the next begins.
|
|
searchWindow = 9 * time.Minute
|
|
// duePage bounds what one pass reads. The sources' pace is the real
|
|
// limit; this caps a pass whose sources are all unpaced.
|
|
duePage = 200
|
|
// grabTries is how many grabbable releases one search tries in turn
|
|
// when the first is refused for its files.
|
|
grabTries = 3
|
|
// passTimeout backs up the window for a call none of the clients'
|
|
// own timeouts cover, such as a wedged database read.
|
|
passTimeout = 15 * time.Minute
|
|
)
|
|
|
|
// ErrWantHeld: the want is already answered by the library, and is not
|
|
// wanted for a better copy.
|
|
var ErrWantHeld = errors.New("wanted: the library already holds this album")
|
|
|
|
// Searcher searches for wants and grabs what they need.
|
|
type Searcher struct {
|
|
pool *pgxpool.Pool
|
|
cat *catalogue.Service
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// NewSearcher returns a Searcher reading editions through cat (nil reads
|
|
// only what the catalogue already holds).
|
|
func NewSearcher(pool *pgxpool.Pool, cat *catalogue.Service, logger *slog.Logger) *Searcher {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &Searcher{pool: pool, cat: cat, logger: logger}
|
|
}
|
|
|
|
// Report is one pass.
|
|
type Report struct {
|
|
Looked int `json:"looked"`
|
|
Grabbed int `json:"grabbed"`
|
|
Waiting int `json:"waiting"`
|
|
Nothing int `json:"nothing"`
|
|
Failed int `json:"failed"`
|
|
// Paced: the pass stopped because the next search would wait past its
|
|
// window for a source's pace.
|
|
Paced bool `json:"paced"`
|
|
// Off: the schedule is switched off. Unconfigured: nothing to search
|
|
// with. Both are states, not faults.
|
|
Off bool `json:"off"`
|
|
Unconfigured bool `json:"unconfigured"`
|
|
AutoGrab bool `json:"auto_grab"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// Run searches every searchTick until ctx ends.
|
|
func (s *Searcher) Run(ctx context.Context) {
|
|
ticker := time.NewTicker(searchTick)
|
|
defer ticker.Stop()
|
|
said := ""
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
rep := s.Pass(ctx)
|
|
// A pass that searched is always logged; an idle one only when the
|
|
// schedule's state changes, so the log says once why it is quiet.
|
|
if state := rep.state(); rep.Looked > 0 || rep.Error != "" || state != said {
|
|
s.logger.Info("wanted: search pass", "state", state, "looked", rep.Looked,
|
|
"grabbed", rep.Grabbed, "waiting", rep.Waiting, "nothing", rep.Nothing,
|
|
"failed", rep.Failed, "paced", rep.Paced, "error", rep.Error)
|
|
said = state
|
|
}
|
|
}
|
|
}
|
|
|
|
// state names why a pass did what it did: off, unconfigured, failed,
|
|
// idle (nothing was due) or searched.
|
|
func (r Report) state() string {
|
|
switch {
|
|
case r.Off:
|
|
return "off"
|
|
case r.Unconfigured:
|
|
return "unconfigured"
|
|
case r.Error != "":
|
|
return "failed"
|
|
case r.Looked == 0:
|
|
return "idle"
|
|
}
|
|
return "searched"
|
|
}
|
|
|
|
// Pass searches for the wants due, paced by the sources, until the window
|
|
// closes, and records what it did.
|
|
func (s *Searcher) Pass(ctx context.Context) Report {
|
|
rep := s.pass(ctx)
|
|
if body, err := json.Marshal(rep); err == nil {
|
|
if err := dbq.New(s.pool).RecordSearchPass(ctx, body); err != nil && ctx.Err() == nil {
|
|
s.logger.Warn("wanted: record search pass", "err", err)
|
|
}
|
|
}
|
|
return rep
|
|
}
|
|
|
|
func (s *Searcher) pass(ctx context.Context) Report {
|
|
var rep Report
|
|
ctx, cancel := context.WithTimeout(ctx, passTimeout)
|
|
defer cancel()
|
|
svc, settings, err := acquire.Build(ctx, s.pool)
|
|
if err != nil {
|
|
rep.Error = indexer.FirstLine(err.Error())
|
|
return rep
|
|
}
|
|
rep.AutoGrab = settings.AutoGrab
|
|
switch {
|
|
case settings.SearchIntervalHours <= 0:
|
|
rep.Off = true
|
|
return rep
|
|
case svc.Search == nil:
|
|
rep.Unconfigured = true
|
|
return rep
|
|
}
|
|
deadline := time.Now().Add(searchWindow)
|
|
due, err := dbq.New(s.pool).ListWantsDueForSearch(ctx, dbq.ListWantsDueForSearchParams{
|
|
Hours: settings.SearchIntervalHours, MaxRows: duePage,
|
|
})
|
|
if err != nil {
|
|
rep.Error = indexer.FirstLine(err.Error())
|
|
return rep
|
|
}
|
|
for _, row := range due {
|
|
if !svc.Ready(ctx, deadline) {
|
|
rep.Paced = ctx.Err() == nil
|
|
return rep
|
|
}
|
|
want := s.wantFor(ctx, searchRow{id: row.ID, artist: row.ArtistName, title: row.Title,
|
|
primary: row.PrimaryType, secondary: row.SecondaryTypes, date: row.FirstReleaseDate,
|
|
group: row.ReleaseGroupMbid, upgrade: row.Upgrade, heldTier: row.HeldTier, held: row.HeldQuality})
|
|
out := s.one(ctx, svc, want, settings.AutoGrab)
|
|
rep.Looked++
|
|
switch out.Outcome {
|
|
case OutcomeGrabbed:
|
|
rep.Grabbed++
|
|
case OutcomeWaiting:
|
|
rep.Waiting++
|
|
case OutcomeNothing:
|
|
rep.Nothing++
|
|
default:
|
|
rep.Failed++
|
|
s.logger.Warn("wanted: search failed", "want", want.Artist+" - "+want.Title, "err", out.Error)
|
|
}
|
|
}
|
|
return rep
|
|
}
|
|
|
|
// searchRow is what a search needs of a wants row.
|
|
type searchRow struct {
|
|
id pgtype.UUID
|
|
artist, title, primary, date, group string
|
|
secondary []string
|
|
// upgrade, heldTier and held: a held album wanted for a better copy
|
|
// (#5524), and the copy it has.
|
|
upgrade, heldTier, held string
|
|
}
|
|
|
|
// wantFor describes a want for the matcher, with its editions' track counts
|
|
// from the catalogue (read from MusicBrainz the first time when it can be).
|
|
// A held album wanted for better quality takes its copy's tier as a floor.
|
|
func (s *Searcher) wantFor(ctx context.Context, r searchRow) acquire.Want {
|
|
w := acquire.Want{ID: r.id, Want: release.Want{
|
|
Artist: r.artist, Title: r.title, Year: yearOf(r.date), PrimaryType: r.primary, SecondaryTypes: r.secondary,
|
|
}}
|
|
if r.upgrade == UpgradeQuality && release.KnownTier(r.heldTier) {
|
|
w.Floor, w.Held = release.Tier(r.heldTier), r.held
|
|
}
|
|
q := dbq.New(s.pool)
|
|
span, err := q.EditionTrackSpan(ctx, r.group)
|
|
if err == nil && span.MinTracks == 0 && s.cat != nil {
|
|
if _, rerr := s.cat.Releases(ctx, r.group); rerr == nil {
|
|
span, err = q.EditionTrackSpan(ctx, r.group)
|
|
}
|
|
}
|
|
if err == nil {
|
|
w.MinTracks, w.MaxTracks = int(span.MinTracks), int(span.MaxTracks)
|
|
}
|
|
return w
|
|
}
|
|
|
|
func yearOf(date string) int {
|
|
if len(date) < 4 {
|
|
return 0
|
|
}
|
|
y, _ := strconv.Atoi(date[:4])
|
|
return y
|
|
}
|
|
|
|
// Searched is what one want's search came to.
|
|
type Searched struct {
|
|
Outcome string `json:"outcome"`
|
|
Considered int `json:"considered"`
|
|
Best string `json:"best_release"`
|
|
Verdict string `json:"best_verdict"`
|
|
Grabbed string `json:"grabbed,omitempty"`
|
|
Narrowed []acquire.Narrowing `json:"narrowed"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// one searches for a want, takes the best release when auto-grab is on, and
|
|
// records what came of it on the want.
|
|
func (s *Searcher) one(ctx context.Context, svc *acquire.Service, want acquire.Want, autoGrab bool) Searched {
|
|
found := svc.For(ctx, want)
|
|
if found.KeepError != "" {
|
|
s.logger.Warn("wanted: searched, but could not keep the candidates", "want", want.Title, "err", found.KeepError)
|
|
}
|
|
out := Searched{Considered: found.Considered, Narrowed: found.Narrowed}
|
|
if out.Narrowed == nil {
|
|
out.Narrowed = []acquire.Narrowing{}
|
|
}
|
|
if len(found.Candidates) > 0 {
|
|
out.Best, out.Verdict = found.Candidates[0].Name, string(found.Candidates[0].Verdict)
|
|
}
|
|
switch {
|
|
case found.Unconfigured:
|
|
out.Outcome, out.Error = OutcomeError, "No indexer is set up, so there is nothing to search with."
|
|
case found.Error != "":
|
|
out.Outcome, out.Error = OutcomeError, found.Error
|
|
case !found.ForAPerson():
|
|
out.Outcome = OutcomeNothing
|
|
case len(found.Grabbable()) == 0 || !autoGrab:
|
|
out.Outcome = OutcomeWaiting
|
|
default:
|
|
out.Outcome = OutcomeWaiting
|
|
s.grabBest(ctx, svc, want, found, &out)
|
|
}
|
|
s.record(ctx, want.ID, out)
|
|
return out
|
|
}
|
|
|
|
// grabBest takes the best grabbable release, trying the next when one is
|
|
// refused for what its files hold.
|
|
func (s *Searcher) grabBest(ctx context.Context, svc *acquire.Service, want acquire.Want, found acquire.Result, out *Searched) {
|
|
for i, c := range found.Grabbable() {
|
|
if i == grabTries {
|
|
return
|
|
}
|
|
took := svc.Grab(ctx, want, &c, found.Candidates, false, acquire.Origin{By: acquire.OriginSchedule})
|
|
switch {
|
|
case took.Added:
|
|
out.Outcome, out.Grabbed, out.Best, out.Verdict = OutcomeGrabbed, c.Name, c.Name, string(acquire.Grab)
|
|
if took.Error != "" {
|
|
s.logger.Warn("wanted: took a release but could not record it", "want", want.Title, "err", took.Error)
|
|
}
|
|
s.logger.Info("wanted: took a release", "want", want.Title, "artist", want.Artist,
|
|
"release", c.Name, "quality", c.Tier, "save_path", took.SavePath)
|
|
return
|
|
case took.Refused:
|
|
continue
|
|
case took.Unconfigured:
|
|
out.Outcome, out.Error = OutcomeError, "No download client is set up, so nothing was taken."
|
|
return
|
|
case took.Contest != nil:
|
|
return
|
|
case took.Error != "":
|
|
out.Outcome, out.Error = OutcomeError, took.Error
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Searcher) record(ctx context.Context, id pgtype.UUID, out Searched) {
|
|
if err := dbq.New(s.pool).RecordWantSearch(ctx, dbq.RecordWantSearchParams{
|
|
ID: id, Outcome: out.Outcome, Considered: int32(out.Considered),
|
|
BestRelease: out.Best, BestVerdict: out.Verdict, SearchError: out.Error,
|
|
}); err != nil && ctx.Err() == nil {
|
|
s.logger.Warn("wanted: record search", "err", err)
|
|
}
|
|
}
|
|
|
|
// SearchNow searches for one want at once, as the schedule would, for a
|
|
// person pressing Search: it spends the sources' pace but never waits on it.
|
|
func (s *Searcher) SearchNow(ctx context.Context, id pgtype.UUID) (Searched, error) {
|
|
want, err := s.load(ctx, id)
|
|
if err != nil {
|
|
return Searched{}, err
|
|
}
|
|
svc, settings, err := acquire.Build(ctx, s.pool)
|
|
if err != nil {
|
|
return Searched{}, err
|
|
}
|
|
return s.one(ctx, svc, want, settings.AutoGrab), nil
|
|
}
|
|
|
|
// Take grabs the release named by key for a person, searching again first.
|
|
func (s *Searcher) Take(ctx context.Context, id pgtype.UUID, key string, force bool, by pgtype.UUID) (acquire.Taken, error) {
|
|
want, err := s.load(ctx, id)
|
|
if err != nil {
|
|
return acquire.Taken{}, err
|
|
}
|
|
svc, _, err := acquire.Build(ctx, s.pool)
|
|
if err != nil {
|
|
return acquire.Taken{}, err
|
|
}
|
|
took := svc.Take(ctx, want, key, force, acquire.Origin{By: acquire.OriginOperator, User: by})
|
|
if took.Added && took.Chosen != nil {
|
|
s.record(ctx, id, Searched{
|
|
Outcome: OutcomeGrabbed, Best: took.Chosen.Name, Verdict: string(took.Chosen.Verdict),
|
|
Considered: 0, Grabbed: took.Chosen.Name,
|
|
})
|
|
}
|
|
return took, nil
|
|
}
|
|
|
|
// load reads a want for a search: an open one, or a held album wanted for a
|
|
// better copy (#5524).
|
|
func (s *Searcher) load(ctx context.Context, id pgtype.UUID) (acquire.Want, error) {
|
|
row, err := dbq.New(s.pool).GetWantForSearch(ctx, id)
|
|
if err != nil {
|
|
return acquire.Want{}, err
|
|
}
|
|
if row.FulfilledAt.Valid && row.Upgrade == "" {
|
|
return acquire.Want{}, ErrWantHeld
|
|
}
|
|
return s.wantFor(ctx, searchRow{id: row.ID, artist: row.ArtistName, title: row.Title,
|
|
primary: row.PrimaryType, secondary: row.SecondaryTypes, date: row.FirstReleaseDate,
|
|
group: row.ReleaseGroupMbid, upgrade: row.Upgrade, heldTier: row.HeldTier, held: row.HeldQuality}), nil
|
|
}
|