-- 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". -- name: GetReacquisitionSettings :one SELECT * FROM reacquisition_settings WHERE id = true; -- name: UpdateReacquisitionSettings :one -- 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) WHERE id = true RETURNING *; -- name: ListAlbumsDueReacquisition :many -- The sweeper's selection. An album qualifies when: -- -- * 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, 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 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, artists.id, artists.name, artists.mbid, r.attempts, r.last_attempt_at ORDER BY r.last_attempt_at NULLS FIRST, 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. SELECT COUNT(DISTINCT albums.id)::bigint FROM albums JOIN artists ON artists.id = albums.artist_id JOIN tracks ON tracks.album_id = albums.id 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() 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[]);