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)
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>
This commit is contained in:
2026-10-11 12:24:59 -04:00
co-authored by Claude Opus 5.5
parent a5ad301c94
commit c54792c51e
45 changed files with 1574 additions and 99 deletions
+137 -3
View File
@@ -20,6 +20,22 @@ func (q *Queries) ClearDefaultReleaseTypeProfile(ctx context.Context) error {
return err
}
const clearHeldUpgrades = `-- name: ClearHeldUpgrades :execrows
UPDATE wants SET upgrade = '', held_tier = '', held_quality = '', upgrade_checked_at = now()
WHERE fulfilled_at IS NOT NULL AND upgrade <> ''
AND NOT EXISTS (SELECT 1 FROM tracks t
WHERE t.album_id = wants.album_id AND t.missing_since IS NULL AND t.duration_ms > 0)
`
// A held want whose album has no track left to judge is not wanted again.
func (q *Queries) ClearHeldUpgrades(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, clearHeldUpgrades)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const countAlbumsToMatch = `-- name: CountAlbumsToMatch :one
SELECT COUNT(*)
FROM albums a
@@ -52,6 +68,8 @@ const countWants = `-- name: CountWants :one
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
-- Held albums wanted for a better copy (#5524).
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL AND upgrade <> '')::bigint AS upgrades,
COUNT(DISTINCT artist_mbid)::bigint AS artists,
-- Library albums waiting to be matched by hand (#5579).
(SELECT COUNT(*) FROM library_album_matches m
@@ -72,6 +90,7 @@ type CountWantsRow struct {
Wanted int64
Missing int64
Held int64
Upgrades int64
Artists int64
Unmatched int64
Following int64
@@ -87,6 +106,7 @@ func (q *Queries) CountWants(ctx context.Context) (CountWantsRow, error) {
&i.Wanted,
&i.Missing,
&i.Held,
&i.Upgrades,
&i.Artists,
&i.Unmatched,
&i.Following,
@@ -350,7 +370,7 @@ func (q *Queries) GetWantAlbum(ctx context.Context, id pgtype.UUID) (GetWantAlbu
}
const getWantByReleaseGroup = `-- name: GetWantByReleaseGroup :one
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error FROM wants WHERE release_group_mbid = $1
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error, upgrade, held_tier, held_quality, upgrade_checked_at FROM wants WHERE release_group_mbid = $1
`
func (q *Queries) GetWantByReleaseGroup(ctx context.Context, releaseGroupMbid string) (Want, error) {
@@ -377,6 +397,10 @@ func (q *Queries) GetWantByReleaseGroup(ctx context.Context, releaseGroupMbid st
&i.BestRelease,
&i.BestVerdict,
&i.SearchError,
&i.Upgrade,
&i.HeldTier,
&i.HeldQuality,
&i.UpgradeCheckedAt,
)
return i, err
}
@@ -700,6 +724,69 @@ func (q *Queries) ListFollowsDue(ctx context.Context, arg ListFollowsDueParams)
return items, nil
}
const listHeldWantFormats = `-- name: ListHeldWantFormats :many
SELECT w.id AS want_id, w.upgrade, w.held_tier, w.held_quality,
lower(t.file_format)::text AS file_format,
SUM(t.file_size)::bigint AS bytes,
SUM(t.duration_ms)::bigint AS duration_ms,
COUNT(*)::int AS tracks,
COUNT(*) FILTER (WHERE t.source_verdict = 'suspect')::int AS rips
FROM wants w
JOIN tracks t ON t.album_id = w.album_id
WHERE w.fulfilled_at IS NOT NULL
AND t.missing_since IS NULL AND t.duration_ms > 0
GROUP BY w.id, lower(t.file_format)
ORDER BY w.id
`
type ListHeldWantFormatsRow struct {
WantID pgtype.UUID
Upgrade string
HeldTier string
HeldQuality string
FileFormat string
Bytes int64
DurationMs int64
Tracks int32
Rips int32
}
// Upgrades (#5524). -------------------------------------------------------
// What each held album's copy is made of, for judging whether it is worth
// upgrading: its present tracks grouped by format, with their bytes and
// length summed, and how many are held back as video rips. A held want whose
// album has no track with a length yet has no rows.
func (q *Queries) ListHeldWantFormats(ctx context.Context) ([]ListHeldWantFormatsRow, error) {
rows, err := q.db.Query(ctx, listHeldWantFormats)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListHeldWantFormatsRow
for rows.Next() {
var i ListHeldWantFormatsRow
if err := rows.Scan(
&i.WantID,
&i.Upgrade,
&i.HeldTier,
&i.HeldQuality,
&i.FileFormat,
&i.Bytes,
&i.DurationMs,
&i.Tracks,
&i.Rips,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLibraryAlbumTracks = `-- name: ListLibraryAlbumTracks :many
SELECT title, disc_number, track_number, duration_ms, (missing_since IS NOT NULL)::bool AS missing
FROM tracks
@@ -779,7 +866,7 @@ func (q *Queries) ListLibraryAlbums(ctx context.Context, arg ListLibraryAlbumsPa
}
const listOpenWants = `-- name: ListOpenWants :many
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error FROM wants
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error, upgrade, held_tier, held_quality, upgrade_checked_at FROM wants
WHERE fulfilled_at IS NULL
ORDER BY wanted_at, id
LIMIT $1
@@ -815,6 +902,10 @@ func (q *Queries) ListOpenWants(ctx context.Context, limit int32) ([]Want, error
&i.BestRelease,
&i.BestVerdict,
&i.SearchError,
&i.Upgrade,
&i.HeldTier,
&i.HeldQuality,
&i.UpgradeCheckedAt,
); err != nil {
return nil, err
}
@@ -1023,6 +1114,8 @@ func (q *Queries) ListWantArtists(ctx context.Context, arg ListWantArtistsParams
const listWants = `-- name: ListWants :many
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
w.album_id, w.wanted_at, w.fulfilled_at,
-- Why a held album is wanted again, and the copy it has (#5524).
w.upgrade, w.held_quality,
-- A request names an album before the catalogue has read it: the
-- catalogue's type and date fill in once it has.
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
@@ -1049,6 +1142,7 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
WHERE CASE $1::text
WHEN 'all' THEN true
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
WHEN 'upgrade' THEN w.fulfilled_at IS NOT NULL AND w.upgrade <> ''
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
@@ -1080,6 +1174,8 @@ type ListWantsRow struct {
AlbumID pgtype.UUID
WantedAt pgtype.Timestamptz
FulfilledAt pgtype.Timestamptz
Upgrade string
HeldQuality string
PrimaryType string
SecondaryTypes []string
FirstReleaseDate string
@@ -1137,6 +1233,8 @@ func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWan
&i.AlbumID,
&i.WantedAt,
&i.FulfilledAt,
&i.Upgrade,
&i.HeldQuality,
&i.PrimaryType,
&i.SecondaryTypes,
&i.FirstReleaseDate,
@@ -1330,6 +1428,38 @@ func (q *Queries) SetDefaultReleaseTypeProfile(ctx context.Context, id pgtype.UU
return result.RowsAffected(), nil
}
const setWantUpgrades = `-- name: SetWantUpgrades :exec
UPDATE wants w
SET upgrade = v.upgrade,
held_tier = v.held_tier,
held_quality = v.held_quality,
upgrade_checked_at = now(),
updated_at = CASE WHEN w.upgrade <> v.upgrade THEN now() ELSE w.updated_at END
FROM (SELECT unnest($1::uuid[]) AS id,
unnest($2::text[]) AS upgrade,
unnest($3::text[]) AS held_tier,
unnest($4::text[]) AS held_quality) v
WHERE w.id = v.id
`
type SetWantUpgradesParams struct {
Ids []pgtype.UUID
Upgrades []string
HeldTiers []string
HeldQualities []string
}
// Writes the upgrade check's verdicts, one row per want.
func (q *Queries) SetWantUpgrades(ctx context.Context, arg SetWantUpgradesParams) error {
_, err := q.db.Exec(ctx, setWantUpgrades,
arg.Ids,
arg.Upgrades,
arg.HeldTiers,
arg.HeldQualities,
)
return err
}
const startLibraryListing = `-- name: StartLibraryListing :one
UPDATE acquisition_settings
SET library_listing_started_at = COALESCE(library_listing_started_at, now())
@@ -1502,7 +1632,7 @@ INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, primary_
VALUES ($1, $2, $3, $4,
$5, $6, $7, $8)
ON CONFLICT (release_group_mbid) DO UPDATE SET updated_at = wants.updated_at
RETURNING id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error
RETURNING id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at, searched_at, search_outcome, search_considered, best_release, best_verdict, search_error, upgrade, held_tier, held_quality, upgrade_checked_at
`
type WantReleaseGroupParams struct {
@@ -1551,6 +1681,10 @@ func (q *Queries) WantReleaseGroup(ctx context.Context, arg WantReleaseGroupPara
&i.BestRelease,
&i.BestVerdict,
&i.SearchError,
&i.Upgrade,
&i.HeldTier,
&i.HeldQuality,
&i.UpgradeCheckedAt,
)
return i, err
}