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
+23
View File
@@ -140,3 +140,26 @@ SELECT b.info_hash, b.name, b.reason, b.detail, b.blocked_at, b.expires_at, w.ti
WHERE b.expires_at IS NULL OR b.expires_at > now()
ORDER BY b.blocked_at DESC, b.info_hash
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: ListUpgradeTracks :many
-- The tracks an upgrade may replace (#5524): the present tracks of the album
-- the want holds.
SELECT id, file_path, mbid, COALESCE(disc_number, 1)::int AS disc, track_number,
file_format, file_size, duration_ms, source_verdict
FROM tracks
WHERE album_id = $1 AND missing_since IS NULL;
-- name: FinishUpgrade :exec
-- An upgrade was imported: the want holds the album the new tracks are in,
-- and is judged again by the next upgrade check.
UPDATE wants
SET album_id = COALESCE(sqlc.narg(album_id), album_id),
upgrade = '',
upgrade_checked_at = NULL,
updated_at = now()
WHERE id = sqlc.arg(id);
-- name: ClearTrackSourceVerdict :exec
-- A track whose file was replaced in place is no longer the rip it was.
UPDATE tracks SET source_verdict = NULL, source_verdict_at = NULL
WHERE id = $1 AND source_verdict = 'suspect';
+14 -7
View File
@@ -12,6 +12,7 @@ UPDATE acquisition_settings
seed_ratio = sqlc.arg(seed_ratio),
seed_days = sqlc.arg(seed_days),
stall_hours = sqlc.arg(stall_hours),
upgrade_until = sqlc.arg(upgrade_until),
updated_at = now()
WHERE id = true
RETURNING *;
@@ -20,25 +21,31 @@ RETURNING *;
UPDATE acquisition_settings SET last_pass_at = now(), last_pass = sqlc.arg(report) WHERE id = true;
-- name: ListWantsDueForSearch :many
-- Open wants with no download running that were never searched or not since
-- @hours ago: never-searched first, then requests, then missing files, then
-- followed artists' releases.
SELECT w.id, w.release_group_mbid, w.artist_name, w.title, w.reason,
-- Wants with no download running that were never searched or not since
-- @hours ago: open wants first (never-searched, then requests, then missing
-- files, then followed artists' releases), then held albums wanted for an
-- upgrade (#5524), which are searched at most weekly.
SELECT w.id, w.release_group_mbid, w.artist_name, w.title, w.reason, w.upgrade, w.held_tier, w.held_quality,
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date
FROM wants w
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
WHERE w.fulfilled_at IS NULL
WHERE (w.fulfilled_at IS NULL OR w.upgrade <> '')
AND NOT EXISTS (SELECT 1 FROM grabs gr WHERE gr.want_id = w.id AND gr.ended_at IS NULL)
AND (w.searched_at IS NULL OR w.searched_at < now() - make_interval(hours => sqlc.arg(hours)::int))
ORDER BY w.searched_at IS NOT NULL,
AND (w.searched_at IS NULL
OR w.searched_at < now() - make_interval(hours => CASE WHEN w.fulfilled_at IS NULL
THEN sqlc.arg(hours)::int
ELSE GREATEST(sqlc.arg(hours)::int, 168) END))
ORDER BY w.fulfilled_at IS NOT NULL,
w.searched_at IS NOT NULL,
CASE w.reason WHEN 'request' THEN 0 WHEN 'missing' THEN 1 ELSE 2 END,
w.searched_at NULLS FIRST, w.wanted_at, w.id
LIMIT sqlc.arg(max_rows);
-- name: GetWantForSearch :one
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason, w.fulfilled_at,
w.upgrade, w.held_tier, w.held_quality, w.album_id,
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date
+7 -6
View File
@@ -288,16 +288,17 @@ SELECT t.id,
artists.name AS artist_name,
t.source_verdict,
t.source_verdict_at,
searched.at AS searched_at
-- The album's place on the wanted list: wanted for a clean copy, and
-- when that was last searched for (#5524).
COALESCE(w.upgrade, '')::text AS want_upgrade,
w.searched_at AS searched_at
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
-- When the resolver last asked Lidarr to search the album for a better copy
-- (#5447).
LEFT JOIN LATERAL (
SELECT max(l.created_at)::timestamptz AS at FROM audit_log l
WHERE l.action = 'lidarr_rip_search' AND l.metadata->>'album_id' = albums.id::text
) searched ON true
SELECT x.upgrade, x.searched_at FROM wants x
WHERE x.album_id = albums.id AND x.fulfilled_at IS NOT NULL LIMIT 1
) w ON true
WHERE t.missing_since IS NULL
AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
+46
View File
@@ -183,6 +183,8 @@ DELETE FROM wants WHERE id = $1;
-- artist or album title; total counts every match, past the page.
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,
@@ -209,6 +211,7 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
WHERE CASE sqlc.arg(state)::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
@@ -270,6 +273,8 @@ SELECT title, disc_number, track_number, duration_ms, (missing_since IS NOT NULL
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
@@ -448,3 +453,44 @@ SELECT a.id, a.title, a.mbid, a.release_group_mbid, ar.name AS artist_name, ar.m
UPDATE library_album_matches SET outcome = sqlc.arg(outcome), decided_at = now()
WHERE album_id = sqlc.arg(album_id) AND outcome = sqlc.arg(was);
-- Upgrades (#5524). -------------------------------------------------------
-- name: ListHeldWantFormats :many
-- 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.
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;
-- name: SetWantUpgrades :exec
-- Writes the upgrade check's verdicts, one row per want.
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(sqlc.arg(ids)::uuid[]) AS id,
unnest(sqlc.arg(upgrades)::text[]) AS upgrade,
unnest(sqlc.arg(held_tiers)::text[]) AS held_tier,
unnest(sqlc.arg(held_qualities)::text[]) AS held_quality) v
WHERE w.id = v.id;
-- name: ClearHeldUpgrades :execrows
-- A held want whose album has no track left to judge is not wanted again.
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);