Answers the open fork on #2527's last slice: automatic, not a button. Until now missing_since was a dead end -- reconcile marks it, every selection path skips it, the admin surface lists it, and there it sits. Two decisions carry most of the safety, both at the design level rather than as rate limits bolted on afterwards. The unit is the ALBUM, not the track. Lidarr acquires releases; there is no meaningful "fetch me one track", and a track-kind request needs a recording MBID plenty of files lack. Grouping means the loss that produced #2523 -- three reorganised albums, ~40 missing files -- becomes three requests instead of forty. The flood problem mostly dissolves. And nothing is requested until a file has been missing longer than the grace window (24h default). A filesystem lies transiently: an unmounted volume, a container that started before its media mount attached, a NAS mid-reboot. Every one of those resolves itself well inside a day at no cost. missing_since is never re-stamped (#2523), so it is a true "gone since" clock to measure against, not "when we last noticed". This is the difference between automatic and trigger-happy. Then the backoff proper: 6h -> 12h -> 24h -> 48h per album, clamped to a week, three attempts before giving up, and a per-pass ceiling so a genuinely large loss trickles instead of dumping hundreds of rows into the queue. Giving up is stamped as a timestamp rather than inferred from attempts >= max, so the verdict survives an operator later raising the maximum and the surface can say when. A sweeper, not a hook inside reconcile. Reconcile runs inside a scan and has no business deciding to talk to a third-party service; it also re-runs often, which would make "attempt once, then back off" awkward to express. A worker paces itself, survives a restart, and retries without needing another scan. Recovered albums have their state deleted rather than reset -- a future loss is a new problem, not a continuation. Requests are attributed to the oldest admin: lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting human, so this keeps the row auditable and in the same queue as everything else without inventing a synthetic principal the schema would have to understand. Auto-approve defaults ON. Requests are created pending and nothing reaches Lidarr until approval, so with it off this would be a notification rather than an attempt. Lidarr disabled leaves the request pending rather than counting a failure -- the record of intent is still right and becomes actionable the moment Lidarr is configured. Albums with no MBID are counted, not silently skipped: nothing can be asked of Lidarr for a release MusicBrainz cannot name, and quietly doing nothing would read as the feature being broken. Settings are DB-backed per rule #25 with CHECK-guarded ranges, validated in Go as well so the API answers 400 rather than surfacing a constraint violation. The admin card and the state on the missing-files page are next; this is the engine.
311 lines
9.9 KiB
Go
311 lines
9.9 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: reacquisition.sql
|
|
|
|
package dbq
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const clearRecoveredReacquisitions = `-- name: ClearRecoveredReacquisitions :execrows
|
|
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
|
|
)
|
|
`
|
|
|
|
// 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.
|
|
func (q *Queries) ClearRecoveredReacquisitions(ctx context.Context) (int64, error) {
|
|
result, err := q.db.Exec(ctx, clearRecoveredReacquisitions)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const countAlbumsMissingWithoutMbid = `-- name: CountAlbumsMissingWithoutMbid :one
|
|
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)
|
|
`
|
|
|
|
// 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.
|
|
func (q *Queries) CountAlbumsMissingWithoutMbid(ctx context.Context) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countAlbumsMissingWithoutMbid)
|
|
var column_1 int64
|
|
err := row.Scan(&column_1)
|
|
return column_1, err
|
|
}
|
|
|
|
const getReacquisitionForAlbums = `-- name: GetReacquisitionForAlbums :many
|
|
SELECT album_id, attempts, last_attempt_at, last_request_id, gave_up_at, created_at, updated_at FROM missing_reacquisitions WHERE album_id = ANY($1::uuid[])
|
|
`
|
|
|
|
// State for the admin missing-files surface, so each directory group can say
|
|
// whether a re-acquisition is in flight, waiting, or given up.
|
|
func (q *Queries) GetReacquisitionForAlbums(ctx context.Context, albumIds []pgtype.UUID) ([]MissingReacquisition, error) {
|
|
rows, err := q.db.Query(ctx, getReacquisitionForAlbums, albumIds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []MissingReacquisition
|
|
for rows.Next() {
|
|
var i MissingReacquisition
|
|
if err := rows.Scan(
|
|
&i.AlbumID,
|
|
&i.Attempts,
|
|
&i.LastAttemptAt,
|
|
&i.LastRequestID,
|
|
&i.GaveUpAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const getReacquisitionSettings = `-- name: GetReacquisitionSettings :one
|
|
|
|
SELECT id, enabled, grace_hours, backoff_base_hours, backoff_max_hours, max_attempts, max_per_pass, auto_approve FROM reacquisition_settings WHERE id = true
|
|
`
|
|
|
|
// 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".
|
|
func (q *Queries) GetReacquisitionSettings(ctx context.Context) (ReacquisitionSetting, error) {
|
|
row := q.db.QueryRow(ctx, getReacquisitionSettings)
|
|
var i ReacquisitionSetting
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Enabled,
|
|
&i.GraceHours,
|
|
&i.BackoffBaseHours,
|
|
&i.BackoffMaxHours,
|
|
&i.MaxAttempts,
|
|
&i.MaxPerPass,
|
|
&i.AutoApprove,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const listAlbumsDueReacquisition = `-- name: ListAlbumsDueReacquisition :many
|
|
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 => $1::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(
|
|
($2::int
|
|
* POWER(2, GREATEST(COALESCE(r.attempts, 0) - 1, 0)))::int,
|
|
$3::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 $4
|
|
`
|
|
|
|
type ListAlbumsDueReacquisitionParams struct {
|
|
GraceHours int32
|
|
BackoffBaseHours int32
|
|
BackoffMaxHours int32
|
|
PageLimit int32
|
|
}
|
|
|
|
type ListAlbumsDueReacquisitionRow struct {
|
|
AlbumID pgtype.UUID
|
|
AlbumTitle string
|
|
AlbumMbid *string
|
|
ArtistID pgtype.UUID
|
|
ArtistName string
|
|
ArtistMbid *string
|
|
MissingTrackCount int64
|
|
Attempts int32
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbumsDueReacquisitionParams) ([]ListAlbumsDueReacquisitionRow, error) {
|
|
rows, err := q.db.Query(ctx, listAlbumsDueReacquisition,
|
|
arg.GraceHours,
|
|
arg.BackoffBaseHours,
|
|
arg.BackoffMaxHours,
|
|
arg.PageLimit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListAlbumsDueReacquisitionRow
|
|
for rows.Next() {
|
|
var i ListAlbumsDueReacquisitionRow
|
|
if err := rows.Scan(
|
|
&i.AlbumID,
|
|
&i.AlbumTitle,
|
|
&i.AlbumMbid,
|
|
&i.ArtistID,
|
|
&i.ArtistName,
|
|
&i.ArtistMbid,
|
|
&i.MissingTrackCount,
|
|
&i.Attempts,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const markReacquisitionGaveUp = `-- name: MarkReacquisitionGaveUp :exec
|
|
UPDATE missing_reacquisitions
|
|
SET gave_up_at = now(),
|
|
updated_at = now()
|
|
WHERE album_id = $1
|
|
AND gave_up_at IS NULL
|
|
`
|
|
|
|
// 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.
|
|
func (q *Queries) MarkReacquisitionGaveUp(ctx context.Context, albumID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, markReacquisitionGaveUp, albumID)
|
|
return err
|
|
}
|
|
|
|
const recordReacquisitionAttempt = `-- name: RecordReacquisitionAttempt :one
|
|
INSERT INTO missing_reacquisitions (album_id, attempts, last_attempt_at, last_request_id)
|
|
VALUES ($1, 1, now(), $2)
|
|
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 album_id, attempts, last_attempt_at, last_request_id, gave_up_at, created_at, updated_at
|
|
`
|
|
|
|
type RecordReacquisitionAttemptParams struct {
|
|
AlbumID pgtype.UUID
|
|
LastRequestID pgtype.UUID
|
|
}
|
|
|
|
// Bumps the attempt counter and stamps the clock the backoff measures from.
|
|
// Upsert because the first attempt has no row yet.
|
|
func (q *Queries) RecordReacquisitionAttempt(ctx context.Context, arg RecordReacquisitionAttemptParams) (MissingReacquisition, error) {
|
|
row := q.db.QueryRow(ctx, recordReacquisitionAttempt, arg.AlbumID, arg.LastRequestID)
|
|
var i MissingReacquisition
|
|
err := row.Scan(
|
|
&i.AlbumID,
|
|
&i.Attempts,
|
|
&i.LastAttemptAt,
|
|
&i.LastRequestID,
|
|
&i.GaveUpAt,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const updateReacquisitionSettings = `-- name: UpdateReacquisitionSettings :one
|
|
UPDATE reacquisition_settings
|
|
SET enabled = $1,
|
|
grace_hours = $2,
|
|
backoff_base_hours = $3,
|
|
backoff_max_hours = $4,
|
|
max_attempts = $5,
|
|
max_per_pass = $6,
|
|
auto_approve = $7
|
|
WHERE id = true
|
|
RETURNING id, enabled, grace_hours, backoff_base_hours, backoff_max_hours, max_attempts, max_per_pass, auto_approve
|
|
`
|
|
|
|
type UpdateReacquisitionSettingsParams struct {
|
|
Enabled bool
|
|
GraceHours int32
|
|
BackoffBaseHours int32
|
|
BackoffMaxHours int32
|
|
MaxAttempts int32
|
|
MaxPerPass int32
|
|
AutoApprove bool
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) UpdateReacquisitionSettings(ctx context.Context, arg UpdateReacquisitionSettingsParams) (ReacquisitionSetting, error) {
|
|
row := q.db.QueryRow(ctx, updateReacquisitionSettings,
|
|
arg.Enabled,
|
|
arg.GraceHours,
|
|
arg.BackoffBaseHours,
|
|
arg.BackoffMaxHours,
|
|
arg.MaxAttempts,
|
|
arg.MaxPerPass,
|
|
arg.AutoApprove,
|
|
)
|
|
var i ReacquisitionSetting
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Enabled,
|
|
&i.GraceHours,
|
|
&i.BackoffBaseHours,
|
|
&i.BackoffMaxHours,
|
|
&i.MaxAttempts,
|
|
&i.MaxPerPass,
|
|
&i.AutoApprove,
|
|
)
|
|
return i, err
|
|
}
|