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,130 +0,0 @@
|
||||
-- name: CreateLidarrRequest :one
|
||||
INSERT INTO lidarr_requests (
|
||||
user_id, kind,
|
||||
lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid,
|
||||
artist_name, album_title, track_title
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetLidarrRequestByID :one
|
||||
SELECT * FROM lidarr_requests WHERE id = $1;
|
||||
|
||||
-- name: ListLidarrRequestsForUser :many
|
||||
SELECT * FROM lidarr_requests
|
||||
WHERE user_id = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListLidarrRequestsByStatus :many
|
||||
SELECT * FROM lidarr_requests
|
||||
WHERE status = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListApprovedLidarrRequestsForReconcile :many
|
||||
SELECT * FROM lidarr_requests
|
||||
WHERE status = 'approved'
|
||||
ORDER BY decided_at ASC
|
||||
LIMIT $1;
|
||||
|
||||
-- name: ApproveLidarrRequest :one
|
||||
UPDATE lidarr_requests
|
||||
SET status = 'approved',
|
||||
quality_profile_id = $2,
|
||||
root_folder_path = $3,
|
||||
decided_at = now(),
|
||||
decided_by = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING *;
|
||||
|
||||
-- name: RejectLidarrRequest :one
|
||||
UPDATE lidarr_requests
|
||||
SET status = 'rejected',
|
||||
notes = $2,
|
||||
decided_at = now(),
|
||||
decided_by = $3,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING *;
|
||||
|
||||
-- name: CancelLidarrRequest :one
|
||||
-- $2 serves as both the ownership guard (user_id = $2) and the
|
||||
-- decided_by audit field. The generated parameter struct names it
|
||||
-- DecidedBy — callers must pass the cancelling user's UUID, and
|
||||
-- a wrong UUID will silently no-op (zero rows updated) rather than
|
||||
-- erroring at the SQL layer.
|
||||
UPDATE lidarr_requests
|
||||
SET status = 'rejected',
|
||||
notes = 'cancelled by user',
|
||||
decided_at = now(),
|
||||
decided_by = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND user_id = $2 AND status = 'pending'
|
||||
RETURNING *;
|
||||
|
||||
-- name: CompleteLidarrRequest :one
|
||||
-- Reconciler transitions an approved request to completed when its
|
||||
-- target track/album/artist has appeared in the library. Callers
|
||||
-- should set ONLY the matched_*_id corresponding to the request's
|
||||
-- kind and pass pgtype.UUID{} (Valid: false) for the others —
|
||||
-- per-kind one-of-three is the contract, NULL for the others
|
||||
-- preserves auditability if the matched row is later evicted.
|
||||
UPDATE lidarr_requests
|
||||
SET status = 'completed',
|
||||
matched_track_id = $2,
|
||||
matched_album_id = $3,
|
||||
matched_artist_id = $4,
|
||||
completed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'approved'
|
||||
RETURNING *;
|
||||
|
||||
-- name: HasNonTerminalRequestForMBID :one
|
||||
-- Returns true if any user has a pending/approved/completed request
|
||||
-- whose MBID matches at the given level. Used to set the `requested`
|
||||
-- flag on /api/lidarr/search responses. Terminal-status (rejected,
|
||||
-- failed) rows do not count. The same MBID parameter is checked
|
||||
-- against artist/album/track columns based on kind.
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM lidarr_requests
|
||||
WHERE status IN ('pending', 'approved', 'completed')
|
||||
AND ((kind = 'artist' AND lidarr_artist_mbid = @mbid)
|
||||
OR (kind = 'album' AND lidarr_album_mbid = @mbid)
|
||||
OR (kind = 'track' AND lidarr_track_mbid = @mbid))
|
||||
);
|
||||
|
||||
-- name: GetNonTerminalRequestForMBID :one
|
||||
-- Returns the oldest non-terminal (pending/approved/completed) request
|
||||
-- whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
|
||||
-- uses this to dedupe: a re-request for something already in flight
|
||||
-- returns the existing row instead of inserting a duplicate. MBIDs are
|
||||
-- globally unique per entity type, so the cross-column OR cannot
|
||||
-- false-match (an artist MBID never collides with an album/track MBID).
|
||||
SELECT * FROM lidarr_requests
|
||||
WHERE status IN ('pending', 'approved', 'completed')
|
||||
AND ((kind = 'artist' AND lidarr_artist_mbid = @mbid)
|
||||
OR (kind = 'album' AND lidarr_album_mbid = @mbid)
|
||||
OR (kind = 'track' AND lidarr_track_mbid = @mbid))
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: MarkLidarrRequestAddConfirmed :exec
|
||||
-- Idempotently records that Lidarr accepted the add for an approved
|
||||
-- request. The lidarr_add_confirmed_at IS NULL guard makes a redundant
|
||||
-- reconciler retry a no-op rather than bumping updated_at every tick.
|
||||
UPDATE lidarr_requests
|
||||
SET lidarr_add_confirmed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
AND status = 'approved'
|
||||
AND lidarr_add_confirmed_at IS NULL;
|
||||
|
||||
-- name: SetLidarrRequestAlbumMbid :exec
|
||||
-- #5241: repoint a request at the album's release group. Requests made before
|
||||
-- the release-group read named albums by MusicBrainz release id, which Lidarr
|
||||
-- does not index; the reconciler rewrites them once it has resolved the group.
|
||||
UPDATE lidarr_requests
|
||||
SET lidarr_album_mbid = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1;
|
||||
@@ -35,8 +35,8 @@ UPDATE contextual_likes SET track_id = sqlc.arg(survivor_id)::uuid WHERE track_i
|
||||
-- name: MergeRepointPlaybackErrors :execrows
|
||||
UPDATE playback_errors SET track_id = sqlc.arg(survivor_id)::uuid WHERE track_id = sqlc.arg(loser_id)::uuid;
|
||||
|
||||
-- name: MergeRepointLidarrRequests :execrows
|
||||
UPDATE lidarr_requests SET matched_track_id = sqlc.arg(survivor_id)::uuid
|
||||
-- name: MergeRepointRequests :execrows
|
||||
UPDATE requests SET matched_track_id = sqlc.arg(survivor_id)::uuid
|
||||
WHERE matched_track_id = sqlc.arg(loser_id)::uuid;
|
||||
|
||||
-- name: MergeRepointPlaylistTracks :many
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -362,9 +362,9 @@ contributions AS (
|
||||
JOIN seeds ON seeds.artist_id = u.seed_artist_id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_requests r
|
||||
SELECT 1 FROM requests r
|
||||
WHERE r.user_id = $1
|
||||
AND r.lidarr_artist_mbid = u.candidate_mbid
|
||||
AND r.artist_mbid = u.candidate_mbid
|
||||
AND r.status NOT IN ('rejected', 'failed')
|
||||
)
|
||||
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
-- name: CreateRequest :one
|
||||
INSERT INTO requests (
|
||||
user_id, kind,
|
||||
artist_mbid, album_mbid, track_mbid,
|
||||
artist_name, album_title, track_title
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetRequestByID :one
|
||||
SELECT * FROM requests WHERE id = $1;
|
||||
|
||||
-- name: ListRequestsForUser :many
|
||||
SELECT * FROM requests
|
||||
WHERE user_id = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListRequestsByStatus :many
|
||||
SELECT * FROM requests
|
||||
WHERE status = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListApprovedRequestsForReconcile :many
|
||||
SELECT * FROM requests
|
||||
WHERE status = 'approved'
|
||||
ORDER BY decided_at ASC
|
||||
LIMIT $1;
|
||||
|
||||
-- name: ApproveRequest :one
|
||||
UPDATE requests
|
||||
SET status = 'approved',
|
||||
decided_at = now(),
|
||||
decided_by = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING *;
|
||||
|
||||
-- name: RejectRequest :one
|
||||
UPDATE requests
|
||||
SET status = 'rejected',
|
||||
notes = $2,
|
||||
decided_at = now(),
|
||||
decided_by = $3,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING *;
|
||||
|
||||
-- name: CancelRequest :one
|
||||
-- $2 serves as both the ownership guard (user_id = $2) and the
|
||||
-- decided_by audit field. The generated parameter struct names it
|
||||
-- DecidedBy — callers must pass the cancelling user's UUID, and
|
||||
-- a wrong UUID will silently no-op (zero rows updated) rather than
|
||||
-- erroring at the SQL layer.
|
||||
UPDATE requests
|
||||
SET status = 'rejected',
|
||||
notes = 'cancelled by user',
|
||||
decided_at = now(),
|
||||
decided_by = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND user_id = $2 AND status = 'pending'
|
||||
RETURNING *;
|
||||
|
||||
-- name: CompleteRequest :one
|
||||
-- Reconciler transitions an approved request to completed when its
|
||||
-- target track/album/artist has appeared in the library. Callers
|
||||
-- should set ONLY the matched_*_id corresponding to the request's
|
||||
-- kind and pass pgtype.UUID{} (Valid: false) for the others —
|
||||
-- per-kind one-of-three is the contract, NULL for the others
|
||||
-- preserves auditability if the matched row is later evicted.
|
||||
UPDATE requests
|
||||
SET status = 'completed',
|
||||
matched_track_id = $2,
|
||||
matched_album_id = $3,
|
||||
matched_artist_id = $4,
|
||||
completed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'approved'
|
||||
RETURNING *;
|
||||
|
||||
-- name: HasNonTerminalRequestForMBID :one
|
||||
-- Whether something is already on its way: a pending, approved or completed
|
||||
-- request at the given level, an open want for the release group, or a
|
||||
-- follow of the artist. Sets the `requested` flag on catalogue search.
|
||||
-- Terminal-status (rejected, failed) requests do not count. MBIDs are unique
|
||||
-- per entity type, so checking one id against every column cannot
|
||||
-- false-match.
|
||||
SELECT (EXISTS (
|
||||
SELECT 1 FROM requests r
|
||||
WHERE r.status IN ('pending', 'approved', 'completed')
|
||||
AND ((r.kind = 'artist' AND r.artist_mbid = @mbid)
|
||||
OR (r.kind = 'album' AND r.album_mbid = @mbid)
|
||||
OR (r.kind = 'track' AND r.track_mbid = @mbid))
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM wants w WHERE w.release_group_mbid = @mbid AND w.fulfilled_at IS NULL
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM follows f WHERE f.artist_mbid = @mbid
|
||||
))::bool AS requested;
|
||||
|
||||
-- name: GetNonTerminalRequestForMBID :one
|
||||
-- Returns the oldest non-terminal (pending/approved/completed) request
|
||||
-- whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
|
||||
-- uses this to dedupe: a re-request for something already in flight
|
||||
-- returns the existing row instead of inserting a duplicate.
|
||||
SELECT * FROM requests
|
||||
WHERE status IN ('pending', 'approved', 'completed')
|
||||
AND ((kind = 'artist' AND artist_mbid = @mbid)
|
||||
OR (kind = 'album' AND album_mbid = @mbid)
|
||||
OR (kind = 'track' AND track_mbid = @mbid))
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: RepointRequestsAlbumMbid :exec
|
||||
-- A want moved from a release id to its group (#5241) takes the requests
|
||||
-- that name it along, so the reconciler finds it.
|
||||
UPDATE requests
|
||||
SET album_mbid = sqlc.arg(new_mbid),
|
||||
updated_at = now()
|
||||
WHERE album_mbid = sqlc.arg(old_mbid);
|
||||
@@ -174,16 +174,3 @@ SELECT u.id, u.timezone FROM users u
|
||||
WHERE pe.user_id = u.id
|
||||
AND pe.started_at > now() - INTERVAL '7 days'
|
||||
);
|
||||
|
||||
-- name: GetOldestAdmin :one
|
||||
-- The account a system-initiated action is attributed to (milestone #290).
|
||||
-- lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting
|
||||
-- human, so the row is owned by the longest-standing admin: it keeps the
|
||||
-- request auditable and puts it in the same admin queue as everything else,
|
||||
-- without inventing a synthetic principal the rest of the schema would have
|
||||
-- to understand. Ordered by id as a tiebreak so the choice is stable across
|
||||
-- calls rather than depending on scan order.
|
||||
SELECT * FROM users
|
||||
WHERE is_admin = true
|
||||
ORDER BY created_at, id
|
||||
LIMIT 1;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
-- The wanted list (M502 #5520): release-type profiles, follows and wants.
|
||||
|
||||
-- name: ListReleaseTypeProfiles :many
|
||||
SELECT p.*,
|
||||
(SELECT COUNT(*) FROM follows f WHERE f.profile_id = p.id)::bigint AS follow_count
|
||||
FROM release_type_profiles p
|
||||
ORDER BY p.is_default DESC, p.name;
|
||||
|
||||
-- name: GetReleaseTypeProfile :one
|
||||
SELECT * FROM release_type_profiles WHERE id = $1;
|
||||
|
||||
-- name: GetDefaultReleaseTypeProfile :one
|
||||
SELECT * FROM release_type_profiles WHERE is_default;
|
||||
|
||||
-- name: CreateReleaseTypeProfile :one
|
||||
INSERT INTO release_type_profiles (name, primary_types, secondary_types)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateReleaseTypeProfile :one
|
||||
UPDATE release_type_profiles
|
||||
SET name = $2,
|
||||
primary_types = $3,
|
||||
secondary_types = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ClearDefaultReleaseTypeProfile :exec
|
||||
UPDATE release_type_profiles SET is_default = false, updated_at = now() WHERE is_default;
|
||||
|
||||
-- name: SetDefaultReleaseTypeProfile :execrows
|
||||
UPDATE release_type_profiles SET is_default = true, updated_at = now() WHERE id = $1;
|
||||
|
||||
-- name: DeleteReleaseTypeProfile :execrows
|
||||
-- The default stays; one in use is refused by the follows foreign key.
|
||||
DELETE FROM release_type_profiles WHERE id = $1 AND NOT is_default;
|
||||
|
||||
-- name: UpsertFollow :one
|
||||
-- Following an artist already followed changes its profile. want_existing
|
||||
-- only ever turns on here; the worker turns it off once applied, and turning
|
||||
-- it on again re-reads the baseline so the existing releases are judged.
|
||||
INSERT INTO follows (artist_mbid, artist_name, profile_id, followed_by, want_existing)
|
||||
VALUES (sqlc.arg(artist_mbid), sqlc.arg(artist_name), sqlc.arg(profile_id),
|
||||
sqlc.narg(followed_by), sqlc.arg(want_existing))
|
||||
ON CONFLICT (artist_mbid) DO UPDATE
|
||||
SET artist_name = EXCLUDED.artist_name,
|
||||
profile_id = EXCLUDED.profile_id,
|
||||
want_existing = follows.want_existing OR EXCLUDED.want_existing,
|
||||
baseline_at = CASE WHEN EXCLUDED.want_existing THEN NULL ELSE follows.baseline_at END,
|
||||
updated_at = now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetFollow :one
|
||||
SELECT f.*, p.name AS profile_name
|
||||
FROM follows f
|
||||
JOIN release_type_profiles p ON p.id = f.profile_id
|
||||
WHERE f.artist_mbid = $1;
|
||||
|
||||
-- name: DeleteFollow :execrows
|
||||
DELETE FROM follows WHERE artist_mbid = $1;
|
||||
|
||||
-- name: ListFollows :many
|
||||
SELECT f.artist_mbid, f.artist_name, f.profile_id, f.followed_at, f.baseline_at, f.checked_at,
|
||||
p.name AS profile_name,
|
||||
(SELECT a.id FROM artists a WHERE a.mbid = f.artist_mbid) AS artist_id,
|
||||
(SELECT COUNT(*) FROM wants w
|
||||
WHERE w.artist_mbid = f.artist_mbid AND w.fulfilled_at IS NULL)::bigint AS open_wants
|
||||
FROM follows f
|
||||
JOIN release_type_profiles p ON p.id = f.profile_id
|
||||
ORDER BY lower(f.artist_name), f.artist_mbid;
|
||||
|
||||
-- name: ListFollowsDue :many
|
||||
-- Follows whose discography has not been applied since @before, never-read
|
||||
-- first, so a new follow's baseline is not stuck behind daily re-checks.
|
||||
SELECT f.*
|
||||
FROM follows f
|
||||
WHERE f.baseline_at IS NULL OR f.checked_at IS NULL OR f.checked_at < sqlc.arg(before)
|
||||
ORDER BY f.baseline_at NULLS FIRST, f.checked_at NULLS FIRST, f.artist_mbid
|
||||
LIMIT sqlc.arg(max_rows);
|
||||
|
||||
-- name: MarkFollowChecked :exec
|
||||
UPDATE follows
|
||||
SET checked_at = now(),
|
||||
baseline_at = COALESCE(baseline_at, now()),
|
||||
want_existing = false,
|
||||
updated_at = now()
|
||||
WHERE artist_mbid = $1;
|
||||
|
||||
-- name: ListFollowGroups :many
|
||||
-- An artist's release groups from the catalogue, each with whether the
|
||||
-- follow has judged it before.
|
||||
SELECT g.mbid, g.title, g.primary_type, g.secondary_types, g.first_release_date,
|
||||
EXISTS (SELECT 1 FROM follow_seen_groups s
|
||||
WHERE s.artist_mbid = sqlc.arg(artist_mbid) AND s.release_group_mbid = g.mbid) AS seen
|
||||
FROM mb_artist_release_groups ag
|
||||
JOIN mb_release_groups g ON g.mbid = ag.release_group_mbid
|
||||
WHERE ag.artist_mbid = sqlc.arg(artist_mbid)
|
||||
ORDER BY g.first_release_date, g.title;
|
||||
|
||||
-- name: MarkGroupSeen :exec
|
||||
INSERT INTO follow_seen_groups (artist_mbid, release_group_mbid)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- name: LibraryHoldsReleaseGroup :one
|
||||
-- Whether the library already has the group: an album tagged with it or
|
||||
-- with one of its releases, or failing that an album of the artist's with
|
||||
-- the same title. Only albums with a track on disk count.
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM albums a
|
||||
WHERE (a.release_group_mbid = sqlc.arg(group_mbid)::text
|
||||
OR a.mbid = sqlc.arg(group_mbid)
|
||||
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = sqlc.arg(group_mbid))
|
||||
OR (lower(a.title) = lower(sqlc.arg(title)::text)
|
||||
AND a.artist_id IN (SELECT ar.id FROM artists ar WHERE ar.mbid = sqlc.arg(artist_mbid)::text)))
|
||||
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)
|
||||
);
|
||||
|
||||
-- name: WantReleaseGroup :one
|
||||
-- Wants a release group, or returns the want already recorded for it. A
|
||||
-- held want is not reopened here: the library has it.
|
||||
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, primary_type,
|
||||
first_release_date, reason, created_by)
|
||||
VALUES (sqlc.arg(release_group_mbid), sqlc.arg(artist_mbid), sqlc.arg(artist_name), sqlc.arg(title),
|
||||
sqlc.arg(primary_type), sqlc.arg(first_release_date), sqlc.arg(reason), sqlc.narg(created_by))
|
||||
ON CONFLICT (release_group_mbid) DO UPDATE SET updated_at = wants.updated_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetWantByReleaseGroup :one
|
||||
SELECT * FROM wants WHERE release_group_mbid = $1;
|
||||
|
||||
-- name: ListOpenWants :many
|
||||
SELECT * FROM wants
|
||||
WHERE fulfilled_at IS NULL
|
||||
ORDER BY wanted_at, id
|
||||
LIMIT $1;
|
||||
|
||||
-- name: WantHoldings :many
|
||||
-- Each library album that may answer a want, with what it has on disk:
|
||||
-- tracks present, whether one arrived since the want, and whether a track
|
||||
-- missing before the want is still missing.
|
||||
SELECT a.id AS album_id,
|
||||
COUNT(*) FILTER (WHERE t.missing_since IS NULL)::int AS present,
|
||||
COALESCE(bool_or(t.missing_since IS NULL AND t.added_at > sqlc.arg(since)::timestamptz), false)::bool AS arrived_since,
|
||||
COALESCE(bool_or(t.missing_since IS NOT NULL AND t.missing_since < sqlc.arg(since)::timestamptz), false)::bool AS missing_before,
|
||||
-- Missing tracks no track on disk stands in for, by position or
|
||||
-- title. A missing row whose song is back at a new path (#5245) is
|
||||
-- covered; a lost bonus track is not.
|
||||
COUNT(*) FILTER (WHERE t.missing_since IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM tracks p
|
||||
WHERE p.album_id = a.id AND p.missing_since IS NULL
|
||||
AND ((p.track_number = t.track_number AND p.disc_number IS NOT DISTINCT FROM t.disc_number)
|
||||
OR lower(p.title) = lower(t.title))))::int AS uncovered_missing
|
||||
FROM albums a
|
||||
JOIN tracks t ON t.album_id = a.id
|
||||
WHERE a.release_group_mbid = sqlc.arg(group_mbid)::text
|
||||
OR a.mbid = sqlc.arg(group_mbid)
|
||||
OR a.id = sqlc.narg(album_id)
|
||||
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = sqlc.arg(group_mbid))
|
||||
GROUP BY a.id
|
||||
ORDER BY present DESC, a.id;
|
||||
|
||||
-- name: FulfillWant :exec
|
||||
UPDATE wants
|
||||
SET fulfilled_at = now(),
|
||||
album_id = sqlc.arg(album_id),
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id) AND fulfilled_at IS NULL;
|
||||
|
||||
-- name: RepointWant :exec
|
||||
-- A want recorded under a release id before #5241 is moved to its group.
|
||||
UPDATE wants SET release_group_mbid = $2, updated_at = now() WHERE id = $1;
|
||||
|
||||
-- name: DeleteWant :execrows
|
||||
DELETE FROM wants WHERE id = $1;
|
||||
|
||||
-- name: ListWants :many
|
||||
-- The Wanted page: open wants (all but missing ones, or only missing ones),
|
||||
-- or what was held most recently.
|
||||
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
|
||||
w.album_id, w.wanted_at, w.fulfilled_at,
|
||||
-- A request names an album before the catalogue has read it: the
|
||||
-- catalogue's type and date fill in once it has.
|
||||
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
|
||||
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
|
||||
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
|
||||
u.username AS requested_by,
|
||||
(SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id
|
||||
FROM wants w
|
||||
LEFT JOIN users u ON u.id = w.created_by
|
||||
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
|
||||
WHERE CASE sqlc.arg(state)::text
|
||||
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
|
||||
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
|
||||
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
|
||||
END
|
||||
ORDER BY COALESCE(w.fulfilled_at, w.wanted_at) DESC, w.id
|
||||
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
|
||||
|
||||
-- name: CountWants :one
|
||||
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
|
||||
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
|
||||
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
|
||||
(SELECT COUNT(*) FROM follows)::bigint AS following
|
||||
FROM wants;
|
||||
Reference in New Issue
Block a user