feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz release group. A want comes from an approved request, a new release by a followed artist, or an album whose files went missing, and is held once the library has a full edition of it. - Migration 0081: release_type_profiles (Standard seeded: Album, EP, Single, studio only), follows, follow_seen_groups and wants. lidarr_requests becomes requests with plain *_mbid columns; approved album and track requests move onto the wanted list, approved artist requests become follows, and the library's album artists start out followed. missing_reacquisitions and the retry settings go. - internal/wanted: follows are checked against MusicBrainz discographies daily. The first check sets a baseline, and only releases after it are wanted unless the follow asked for what already exists. Profiles filter by primary and secondary type, so live recordings stay out unless a profile ticks Live. - internal/requests (was lidarrrequests): approving puts the request on the wanted list, and an artist request can be approved on a chosen profile. A request completes when its want is held. - The re-acquisition sweeper wants a missing album back instead of asking Lidarr; searching for it is the search schedule's (#5521). - Web: an admin Wanted page (Wanted, Missing files, Held, Following), a follow control on artist pages, a release-type profiles card on Integrations, "Approve on a profile" on Requests, and want state on Missing files. The artist detail API returns the artist's MBID. - Android: the request body sends artist_mbid, album_mbid and track_mbid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
-- Auto re-acquisition of missing files (milestone #290). The unit is the
|
||||
-- album: Lidarr acquires releases, and grouping collapses "40 missing files"
|
||||
-- into "3 albums to ask for".
|
||||
-- Re-acquisition of missing files (milestone #290, reworked for M502 #5520).
|
||||
-- The unit is the album: an album whose files went missing becomes a want for
|
||||
-- its release group, and the search schedule (#5521) looks for it.
|
||||
|
||||
-- name: GetReacquisitionSettings :one
|
||||
SELECT * FROM reacquisition_settings WHERE id = true;
|
||||
@@ -9,13 +9,9 @@ SELECT * FROM reacquisition_settings WHERE id = true;
|
||||
-- Whole-row write from the admin card; the CHECKs in migration 0056 are the
|
||||
-- validation, so a bad value fails loudly rather than being clamped silently.
|
||||
UPDATE reacquisition_settings
|
||||
SET enabled = sqlc.arg(enabled),
|
||||
grace_hours = sqlc.arg(grace_hours),
|
||||
backoff_base_hours = sqlc.arg(backoff_base_hours),
|
||||
backoff_max_hours = sqlc.arg(backoff_max_hours),
|
||||
max_attempts = sqlc.arg(max_attempts),
|
||||
max_per_pass = sqlc.arg(max_per_pass),
|
||||
auto_approve = sqlc.arg(auto_approve)
|
||||
SET enabled = sqlc.arg(enabled),
|
||||
grace_hours = sqlc.arg(grace_hours),
|
||||
max_per_pass = sqlc.arg(max_per_pass)
|
||||
WHERE id = true
|
||||
RETURNING *;
|
||||
|
||||
@@ -24,54 +20,40 @@ RETURNING *;
|
||||
--
|
||||
-- * it still has at least one track whose file has been missing longer than
|
||||
-- the grace window. Measured on missing_since, which reconcile never
|
||||
-- re-stamps (#2523), so it is a genuine "gone since" clock rather than
|
||||
-- "when we last noticed";
|
||||
-- * MusicBrainz can name it. Both the album and its artist MBID are
|
||||
-- required — Create rejects an album-kind request without them, and there
|
||||
-- is nothing to ask Lidarr for anyway. Albums failing this are counted
|
||||
-- separately (CountAlbumsMissingWithoutMbid) rather than vanishing;
|
||||
-- * it has not spent its attempt budget (gave_up_at IS NULL);
|
||||
-- * its backoff has elapsed: base * 2^(attempts-1) hours since the last
|
||||
-- attempt, clamped to the configured maximum. First attempt (no row, or
|
||||
-- last_attempt_at NULL) is always due.
|
||||
--
|
||||
-- Oldest attempt first, never-attempted first, so a large loss drains in a
|
||||
-- stable order across passes instead of re-picking the same head each time.
|
||||
SELECT albums.id AS album_id,
|
||||
albums.title AS album_title,
|
||||
albums.mbid AS album_mbid,
|
||||
-- re-stamps (#2523), so it is a genuine "gone since" clock;
|
||||
-- * MusicBrainz can name it: the album and its artist have MBIDs. Albums
|
||||
-- failing this are counted separately (CountAlbumsMissingWithoutMbid);
|
||||
-- * no want already covers it: none open, and none held since its files
|
||||
-- went missing. A want held after the loss means the library has a full
|
||||
-- copy despite the missing rows (#5245), so asking again would loop.
|
||||
SELECT albums.id AS album_id,
|
||||
albums.title AS album_title,
|
||||
albums.mbid AS album_mbid,
|
||||
albums.release_group_mbid AS album_release_group_mbid,
|
||||
artists.id AS artist_id,
|
||||
artists.name AS artist_name,
|
||||
artists.mbid AS artist_mbid,
|
||||
COUNT(tracks.id)::bigint AS missing_track_count,
|
||||
COALESCE(r.attempts, 0)::int AS attempts
|
||||
artists.name AS artist_name,
|
||||
artists.mbid AS artist_mbid,
|
||||
COUNT(tracks.id)::bigint AS missing_track_count,
|
||||
MAX(tracks.missing_since)::timestamptz AS last_missing_since
|
||||
FROM albums
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
JOIN tracks ON tracks.album_id = albums.id
|
||||
LEFT JOIN missing_reacquisitions r ON r.album_id = albums.id
|
||||
WHERE tracks.missing_since IS NOT NULL
|
||||
AND tracks.missing_since <= now() - make_interval(hours => sqlc.arg(grace_hours)::int)
|
||||
AND albums.mbid IS NOT NULL
|
||||
AND artists.mbid IS NOT NULL
|
||||
AND (r.gave_up_at IS NULL)
|
||||
AND (
|
||||
r.last_attempt_at IS NULL
|
||||
OR r.last_attempt_at <= now() - make_interval(hours => LEAST(
|
||||
(sqlc.arg(backoff_base_hours)::int
|
||||
* POWER(2, GREATEST(COALESCE(r.attempts, 0) - 1, 0)))::int,
|
||||
sqlc.arg(backoff_max_hours)::int))
|
||||
)
|
||||
GROUP BY albums.id, albums.title, albums.mbid, albums.release_group_mbid,
|
||||
artists.id, artists.name, artists.mbid, r.attempts, r.last_attempt_at
|
||||
ORDER BY r.last_attempt_at NULLS FIRST, albums.sort_title
|
||||
artists.name, artists.mbid
|
||||
HAVING NOT EXISTS (
|
||||
SELECT 1 FROM wants w
|
||||
WHERE (w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
|
||||
AND (w.fulfilled_at IS NULL OR w.fulfilled_at > MAX(tracks.missing_since)))
|
||||
ORDER BY MIN(tracks.missing_since), albums.sort_title
|
||||
LIMIT sqlc.arg(page_limit);
|
||||
|
||||
-- name: CountAlbumsMissingWithoutMbid :one
|
||||
-- Albums with missing files that can never be auto-requested because nothing
|
||||
-- identifies them to MusicBrainz. Surfaced on the admin card so the gap is
|
||||
-- visible: silently doing nothing for these would read as the feature being
|
||||
-- broken.
|
||||
-- Albums with missing files that can never be wanted automatically because
|
||||
-- nothing identifies them to MusicBrainz. Surfaced on the admin card so the
|
||||
-- gap is visible: silently doing nothing for these would read as broken.
|
||||
SELECT COUNT(DISTINCT albums.id)::bigint
|
||||
FROM albums
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
@@ -79,42 +61,25 @@ SELECT COUNT(DISTINCT albums.id)::bigint
|
||||
WHERE tracks.missing_since IS NOT NULL
|
||||
AND (albums.mbid IS NULL OR artists.mbid IS NULL);
|
||||
|
||||
-- name: RecordReacquisitionAttempt :one
|
||||
-- Bumps the attempt counter and stamps the clock the backoff measures from.
|
||||
-- Upsert because the first attempt has no row yet.
|
||||
INSERT INTO missing_reacquisitions (album_id, attempts, last_attempt_at, last_request_id)
|
||||
VALUES (sqlc.arg(album_id), 1, now(), sqlc.narg(last_request_id))
|
||||
ON CONFLICT (album_id) DO UPDATE
|
||||
SET attempts = missing_reacquisitions.attempts + 1,
|
||||
last_attempt_at = now(),
|
||||
last_request_id = COALESCE(EXCLUDED.last_request_id,
|
||||
missing_reacquisitions.last_request_id),
|
||||
updated_at = now()
|
||||
-- name: WantMissingAlbum :one
|
||||
-- Records an album with missing files as wanted. A held want for the same
|
||||
-- group is reopened; an open one is left as it is, and no row comes back.
|
||||
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id)
|
||||
VALUES (sqlc.arg(release_group_mbid), sqlc.arg(artist_mbid), sqlc.arg(artist_name),
|
||||
sqlc.arg(title), 'missing', sqlc.arg(album_id))
|
||||
ON CONFLICT (release_group_mbid) DO UPDATE
|
||||
SET reason = 'missing',
|
||||
album_id = EXCLUDED.album_id,
|
||||
wanted_at = now(),
|
||||
fulfilled_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE wants.fulfilled_at IS NOT NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: MarkReacquisitionGaveUp :exec
|
||||
-- Stamped when the attempt budget is spent. Stored as a timestamp rather than
|
||||
-- inferred from `attempts >= max_attempts` so the verdict survives an operator
|
||||
-- later raising the maximum, and so the admin surface can say when.
|
||||
UPDATE missing_reacquisitions
|
||||
SET gave_up_at = now(),
|
||||
updated_at = now()
|
||||
WHERE album_id = sqlc.arg(album_id)
|
||||
AND gave_up_at IS NULL;
|
||||
|
||||
-- name: ClearRecoveredReacquisitions :execrows
|
||||
-- Drops state for albums that no longer have any missing track — the files
|
||||
-- came back, or the scanner adopted them at a new path (#2528). Deleting
|
||||
-- rather than resetting counters means a future loss starts from a clean
|
||||
-- budget, which is right: it is a new problem, not a continuation.
|
||||
DELETE FROM missing_reacquisitions r
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM tracks
|
||||
WHERE tracks.album_id = r.album_id
|
||||
AND tracks.missing_since IS NOT NULL
|
||||
);
|
||||
|
||||
-- name: GetReacquisitionForAlbums :many
|
||||
-- State for the admin missing-files surface, so each directory group can say
|
||||
-- whether a re-acquisition is in flight, waiting, or given up.
|
||||
SELECT * FROM missing_reacquisitions WHERE album_id = ANY(sqlc.arg(album_ids)::uuid[]);
|
||||
-- name: GetWantsForAlbums :many
|
||||
-- What the wanted list says about each album on the missing-files page.
|
||||
SELECT a.id AS album_id, w.id AS want_id, w.wanted_at, w.fulfilled_at
|
||||
FROM albums a
|
||||
JOIN wants w ON w.album_id = a.id OR w.release_group_mbid = a.release_group_mbid
|
||||
WHERE a.id = ANY(sqlc.arg(album_ids)::uuid[])
|
||||
ORDER BY w.wanted_at DESC;
|
||||
|
||||
Reference in New Issue
Block a user