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

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:
2026-10-10 00:46:22 -04:00
co-authored by Claude Opus 5.5
parent 867f52358c
commit 04515a977c
93 changed files with 6522 additions and 4484 deletions
+129 -188
View File
@@ -11,27 +11,6 @@ import (
"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
@@ -41,10 +20,9 @@ SELECT COUNT(DISTINCT albums.id)::bigint
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.
// 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.
func (q *Queries) CountAlbumsMissingWithoutMbid(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, countAlbumsMissingWithoutMbid)
var column_1 int64
@@ -52,29 +30,56 @@ func (q *Queries) CountAlbumsMissingWithoutMbid(ctx context.Context) (int64, err
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[])
const getReacquisitionSettings = `-- name: GetReacquisitionSettings :one
SELECT id, enabled, grace_hours, max_per_pass FROM reacquisition_settings WHERE id = true
`
// 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)
// 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.
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.MaxPerPass,
)
return i, err
}
const getWantsForAlbums = `-- name: GetWantsForAlbums :many
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($1::uuid[])
ORDER BY w.wanted_at DESC
`
type GetWantsForAlbumsRow struct {
AlbumID pgtype.UUID
WantID pgtype.UUID
WantedAt pgtype.Timestamptz
FulfilledAt pgtype.Timestamptz
}
// What the wanted list says about each album on the missing-files page.
func (q *Queries) GetWantsForAlbums(ctx context.Context, albumIds []pgtype.UUID) ([]GetWantsForAlbumsRow, error) {
rows, err := q.db.Query(ctx, getWantsForAlbums, albumIds)
if err != nil {
return nil, err
}
defer rows.Close()
var items []MissingReacquisition
var items []GetWantsForAlbumsRow
for rows.Next() {
var i MissingReacquisition
var i GetWantsForAlbumsRow
if err := rows.Scan(
&i.AlbumID,
&i.Attempts,
&i.LastAttemptAt,
&i.LastRequestID,
&i.GaveUpAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.WantID,
&i.WantedAt,
&i.FulfilledAt,
); err != nil {
return nil, err
}
@@ -86,67 +91,35 @@ func (q *Queries) GetReacquisitionForAlbums(ctx context.Context, albumIds []pgty
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,
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 => $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
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 $2
`
type ListAlbumsDueReacquisitionParams struct {
GraceHours int32
BackoffBaseHours int32
BackoffMaxHours int32
PageLimit int32
GraceHours int32
PageLimit int32
}
type ListAlbumsDueReacquisitionRow struct {
@@ -154,37 +127,24 @@ type ListAlbumsDueReacquisitionRow struct {
AlbumTitle string
AlbumMbid *string
AlbumReleaseGroupMbid *string
ArtistID pgtype.UUID
ArtistName string
ArtistMbid *string
MissingTrackCount int64
Attempts int32
LastMissingSince pgtype.Timestamptz
}
// 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.
// 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.
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,
)
rows, err := q.db.Query(ctx, listAlbumsDueReacquisition, arg.GraceHours, arg.PageLimit)
if err != nil {
return nil, err
}
@@ -197,11 +157,10 @@ func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbums
&i.AlbumTitle,
&i.AlbumMbid,
&i.AlbumReleaseGroupMbid,
&i.ArtistID,
&i.ArtistName,
&i.ArtistMbid,
&i.MissingTrackCount,
&i.Attempts,
&i.LastMissingSince,
); err != nil {
return nil, err
}
@@ -213,101 +172,83 @@ func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbums
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
SET enabled = $1,
grace_hours = $2,
max_per_pass = $3
WHERE id = true
RETURNING id, enabled, grace_hours, backoff_base_hours, backoff_max_hours, max_attempts, max_per_pass, auto_approve
RETURNING id, enabled, grace_hours, max_per_pass
`
type UpdateReacquisitionSettingsParams struct {
Enabled bool
GraceHours int32
BackoffBaseHours int32
BackoffMaxHours int32
MaxAttempts int32
MaxPerPass int32
AutoApprove bool
Enabled bool
GraceHours int32
MaxPerPass int32
}
// 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,
)
row := q.db.QueryRow(ctx, updateReacquisitionSettings, arg.Enabled, arg.GraceHours, arg.MaxPerPass)
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 wantMissingAlbum = `-- name: WantMissingAlbum :one
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id)
VALUES ($1, $2, $3,
$4, 'missing', $5)
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 id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at
`
type WantMissingAlbumParams struct {
ReleaseGroupMbid string
ArtistMbid string
ArtistName string
Title string
AlbumID pgtype.UUID
}
// 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.
func (q *Queries) WantMissingAlbum(ctx context.Context, arg WantMissingAlbumParams) (Want, error) {
row := q.db.QueryRow(ctx, wantMissingAlbum,
arg.ReleaseGroupMbid,
arg.ArtistMbid,
arg.ArtistName,
arg.Title,
arg.AlbumID,
)
var i Want
err := row.Scan(
&i.ID,
&i.ReleaseGroupMbid,
&i.ArtistMbid,
&i.ArtistName,
&i.Title,
&i.PrimaryType,
&i.FirstReleaseDate,
&i.Reason,
&i.AlbumID,
&i.CreatedBy,
&i.WantedAt,
&i.FulfilledAt,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}