Files
bvandeusenandClaude Opus 5.5 c54792c51e
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
feat: a held album below the quality you settle for, or holding a video rip, stays wanted for a better copy, and the import replaces the old tracks with their likes and plays carried over (M502 #5524)
- 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>
2026-10-11 12:24:59 -04:00

126 lines
3.7 KiB
Go

package wanted
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
// Upgrades (M502 #5524): a held album stays wanted when the copy the library
// has is below the quality the operator settles for (acquisition_settings.
// upgrade_until), or when one of its tracks is a video rip. The search
// schedule then looks for a better release weekly, and the import replaces
// the old tracks with the new ones, carrying their history across.
// Why a held album is wanted again, matching wants_upgrade_check.
const (
UpgradeQuality = "quality"
UpgradeRip = "rip"
)
// upgradeCheckEvery is how often held albums are judged. Their files change
// only by an import or a scan, and the check reads every held album.
const upgradeCheckEvery = time.Hour
// UpgradeFor says why a held album is wanted again, if it is: a video rip
// among its tracks always (a rip is no copy of the album, whatever the
// setting), else a copy below until. until "" switches quality upgrades off.
func UpgradeFor(held release.Held, judged bool, rips int, until release.Tier) string {
switch {
case rips > 0:
return UpgradeRip
case until == "" || !judged:
return ""
case release.Rank(held.Tier) < release.Rank(until):
return UpgradeQuality
}
return ""
}
// heldVerdict is one held want as the check judged it.
type heldVerdict struct {
id pgtype.UUID
upgrade, tier, words string
oldUpgrade, oldTier, old string
}
func (v heldVerdict) changed() bool {
return v.upgrade != v.oldUpgrade || v.tier != v.oldTier || v.words != v.old
}
// checkUpgrades judges every held album, at most once an hour, and records
// which are wanted for a better copy.
func (s *Service) checkUpgrades(ctx context.Context, res *PassResult) error {
if !s.upgradesChecked.IsZero() && s.now().Sub(s.upgradesChecked) < upgradeCheckEvery {
return nil
}
q := dbq.New(s.pool)
settings, err := q.GetAcquisitionSettings(ctx)
if err != nil {
return fmt.Errorf("read settings: %w", err)
}
rows, err := q.ListHeldWantFormats(ctx)
if err != nil {
return fmt.Errorf("read held albums: %w", err)
}
until := release.Tier(settings.UpgradeUntil)
var out []heldVerdict
flush := func(id pgtype.UUID, group []dbq.ListHeldWantFormatsRow) {
if len(group) == 0 {
return
}
formats := make([]release.HeldFormat, 0, len(group))
rips := 0
for _, r := range group {
formats = append(formats, release.HeldFormat{Format: r.FileFormat, Bytes: r.Bytes, Ms: r.DurationMs})
rips += int(r.Rips)
}
held, judged := release.HeldCopy(formats)
v := heldVerdict{id: id, upgrade: UpgradeFor(held, judged, rips, until),
oldUpgrade: group[0].Upgrade, oldTier: group[0].HeldTier, old: group[0].HeldQuality}
if judged {
v.tier, v.words = string(held.Tier), held.Words
}
if v.upgrade != "" {
res.Upgrades++
}
if v.changed() {
out = append(out, v)
}
}
var group []dbq.ListHeldWantFormatsRow
for _, r := range rows {
if len(group) > 0 && group[0].WantID != r.WantID {
flush(group[0].WantID, group)
group = group[:0]
}
group = append(group, r)
}
if len(group) > 0 {
flush(group[0].WantID, group)
}
if len(out) > 0 {
p := dbq.SetWantUpgradesParams{}
for _, v := range out {
p.Ids = append(p.Ids, v.id)
p.Upgrades = append(p.Upgrades, v.upgrade)
p.HeldTiers = append(p.HeldTiers, v.tier)
p.HeldQualities = append(p.HeldQualities, v.words)
}
if err := q.SetWantUpgrades(ctx, p); err != nil {
return fmt.Errorf("record upgrades: %w", err)
}
}
if _, err := q.ClearHeldUpgrades(ctx); err != nil {
return fmt.Errorf("clear upgrades: %w", err)
}
s.upgradesChecked = s.now()
return nil
}