release / go (push) Successful in 2m33s
release / web (push) Successful in 1m41s
release / govulncheck (push) Successful in 22s
release / integration (push) Successful in 6m0s
release / android (push) Successful in 6m7s
release / Build signed APK (releases and dev) (push) Successful in 6m8s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m43s
release / Verify release artifacts (tag releases only) (push) Skipped
Lidarr's metadata is keyed by MusicBrainz release group, but re-acquisition requested albums by their release id, so every add came back "not found". - Sweeper requests an album by its tag-supplied release group, else the one MusicBrainz names (cached onto the album). An album MusicBrainz cannot name is skipped and counted, with no attempt spent. - Reconciler: an add refused as not found re-reads the request's album id as a release (library first, then MusicBrainz), rewrites the request to the group and adds again. This repairs the requests already stored. - Completion matches an album by release id or release group, and only once a track of it is on disk, so a re-acquisition request no longer completes against the row of the album it is trying to bring back. Closes #5241. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
314 lines
10 KiB
Go
314 lines
10 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,
|
|
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
|
|
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, 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
|
|
LIMIT $4
|
|
`
|
|
|
|
type ListAlbumsDueReacquisitionParams struct {
|
|
GraceHours int32
|
|
BackoffBaseHours int32
|
|
BackoffMaxHours int32
|
|
PageLimit int32
|
|
}
|
|
|
|
type ListAlbumsDueReacquisitionRow struct {
|
|
AlbumID pgtype.UUID
|
|
AlbumTitle string
|
|
AlbumMbid *string
|
|
AlbumReleaseGroupMbid *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.AlbumReleaseGroupMbid,
|
|
&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
|
|
}
|