release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m26s
release / go (push) Successful in 1m45s
release / integration (push) Successful in 5m6s
release / android (push) Successful in 5m58s
release / Build signed APK (releases and dev) (push) Successful in 6m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
The operator chose option 2: an album holding a video rip that is the only copy of its song is searched in Lidarr (AlbumSearch), at most once a week and ten albums a pass. Lidarr grabs only when the quality profile allows an upgrade, so nothing is removed and the rip stays held back either way. - lidarr.Client.SearchAlbums posts the AlbumSearch command. - ListAlbumsWithSoleCopyRips finds the albums: a held-back rip with no present clean copy sharing its song or its pending group, and not searched in the last week (audit action lidarr_rip_search). - Suspect sources shows when Lidarr last searched the track's album. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
374 lines
16 KiB
SQL
374 lines
16 KiB
SQL
-- name: UpsertTrack :one
|
|
-- file_path is the canonical identity for library scan; mbid is secondary.
|
|
-- mbid is the file's tag. A looked-up id (mbid_source 'acoustid', M401)
|
|
-- survives a re-read that finds no tag id, and a tag id replaces it (D4).
|
|
INSERT INTO tracks (
|
|
title, album_id, artist_id, track_number, disc_number,
|
|
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre,
|
|
tag_read_version, mbid_source
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
|
CASE WHEN $11::text IS NOT NULL THEN 'tag' END)
|
|
ON CONFLICT (file_path) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
album_id = EXCLUDED.album_id,
|
|
artist_id = EXCLUDED.artist_id,
|
|
track_number = EXCLUDED.track_number,
|
|
disc_number = EXCLUDED.disc_number,
|
|
duration_ms = EXCLUDED.duration_ms,
|
|
file_size = EXCLUDED.file_size,
|
|
file_format = EXCLUDED.file_format,
|
|
bitrate = EXCLUDED.bitrate,
|
|
-- Both read the row as it was before this statement.
|
|
mbid = CASE WHEN EXCLUDED.mbid IS NULL AND tracks.mbid_source = 'acoustid'
|
|
THEN tracks.mbid ELSE EXCLUDED.mbid END,
|
|
mbid_source = CASE WHEN EXCLUDED.mbid IS NOT NULL THEN 'tag'
|
|
WHEN tracks.mbid_source = 'acoustid' THEN 'acoustid' END,
|
|
genre = EXCLUDED.genre,
|
|
-- Stamped on update too, so a tag-repair pass marks rows as done and the
|
|
-- next scan can short-circuit them again (#2499).
|
|
tag_read_version = EXCLUDED.tag_read_version,
|
|
updated_at = now()
|
|
RETURNING *;
|
|
|
|
-- name: ListTracksMissingMbidWithPath :many
|
|
-- Track recording-MBID backfill: tracks with NULL mbid that still have
|
|
-- a file to re-read. $1 caps the batch (mirrors the album backfill).
|
|
-- Missing tracks are skipped: there is no file, and opening it anyway
|
|
-- logged a warning per track on every scan (#5139).
|
|
SELECT id, file_path
|
|
FROM tracks
|
|
WHERE mbid IS NULL
|
|
AND missing_since IS NULL
|
|
ORDER BY id
|
|
LIMIT $1;
|
|
|
|
-- name: SetTrackMbidIfNull :exec
|
|
-- Heal a track's recording MBID only while still NULL — idempotent, so
|
|
-- re-running the backfill is a no-op for already-healed rows.
|
|
UPDATE tracks
|
|
SET mbid = $2, mbid_source = 'tag', updated_at = now()
|
|
WHERE id = $1 AND mbid IS NULL;
|
|
|
|
-- name: GetTrackByID :one
|
|
SELECT * FROM tracks WHERE id = $1;
|
|
|
|
-- name: GetTrackByPath :one
|
|
SELECT * FROM tracks WHERE file_path = $1;
|
|
|
|
-- name: ListTracksByAlbum :many
|
|
-- $1 = album_id, $2 = user_id. Pass pgtype.UUID{Valid: false} (NULL)
|
|
-- to skip the per-user quarantine filter; the NOT EXISTS clause on
|
|
-- a NULL user_id never matches a row, so every track passes through.
|
|
SELECT * FROM tracks
|
|
WHERE album_id = $1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
|
)
|
|
ORDER BY disc_number NULLS LAST, track_number NULLS LAST;
|
|
|
|
-- name: CountTracksByAlbum :one
|
|
SELECT count(*) FROM tracks WHERE album_id = $1;
|
|
|
|
-- name: SearchTracks :many
|
|
-- $1 = title query, $2 = user_id (NULL to skip quarantine filter),
|
|
-- $3 = limit, $4 = offset.
|
|
SELECT * FROM tracks
|
|
WHERE title ILIKE '%' || $1::text || '%'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
|
)
|
|
ORDER BY title
|
|
LIMIT $3 OFFSET $4;
|
|
|
|
-- name: CountTracksMatching :one
|
|
-- $1 = title query, $2 = user_id (NULL to skip quarantine filter).
|
|
SELECT COUNT(*) FROM tracks
|
|
WHERE title ILIKE '%' || $1::text || '%'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $2 AND q.track_id = tracks.id
|
|
);
|
|
|
|
-- name: ListArtistTracksForUser :many
|
|
-- M6a: every track for the artist across their albums, with album_title
|
|
-- and artist_name joined. Honors per-user lidarr_quarantine. Used by
|
|
-- /api/artists/{id}/tracks for the artist-card play affordance, which
|
|
-- shuffles client-side. Ordering matches album/track natural order so
|
|
-- the shuffle has a deterministic input.
|
|
SELECT sqlc.embed(t),
|
|
albums.title AS album_title,
|
|
artists.name AS artist_name
|
|
FROM tracks t
|
|
JOIN albums ON albums.id = t.album_id
|
|
JOIN artists ON artists.id = t.artist_id
|
|
WHERE t.artist_id = $1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $2 AND q.track_id = t.id
|
|
)
|
|
ORDER BY albums.release_date NULLS LAST, albums.sort_title,
|
|
t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id;
|
|
|
|
-- name: ListRandomTracksForUser :many
|
|
-- #427 S4: backing query for GET /api/library/shuffle — the online
|
|
-- source for "Shuffle all". N random tracks across the whole
|
|
-- library, per-user-quarantine filtered. $1 user_id, $2 limit.
|
|
SELECT sqlc.embed(t),
|
|
albums.title AS album_title,
|
|
artists.name AS artist_name
|
|
FROM tracks t
|
|
JOIN albums ON albums.id = t.album_id
|
|
JOIN artists ON artists.id = t.artist_id
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
)
|
|
ORDER BY random()
|
|
LIMIT $2;
|
|
|
|
-- name: CountTracksByArtist :one
|
|
-- Used by request-progress reporting to count tracks ingested under a
|
|
-- matched artist (sum across all the artist's albums) while a request
|
|
-- is still in flight.
|
|
SELECT COUNT(*) FROM tracks WHERE artist_id = $1;
|
|
|
|
-- name: DeleteTrack :one
|
|
-- M7 #372: hard delete with FK cascade. The CASCADE on track_id from
|
|
-- play_events / general_likes_tracks / lidarr_quarantine /
|
|
-- lidarr_quarantine_actions handles their cleanup. RETURNING gives us
|
|
-- album_id + artist_id for the album-empty / artist-empty cascade
|
|
-- checks the service does next.
|
|
DELETE FROM tracks WHERE id = $1
|
|
RETURNING id, album_id, artist_id, file_path, mbid;
|
|
|
|
-- name: GetTracksByIDs :many
|
|
-- Batched lookup used by /api/library/sync to hydrate upsert payloads
|
|
-- (#357). Mirror of GetArtistsByIDs.
|
|
SELECT * FROM tracks WHERE id = ANY($1::uuid[]);
|
|
|
|
-- name: FindMissingTrackByMbid :many
|
|
-- Move detection, strongest signal (#2528). A file that turned up at a new path
|
|
-- carrying a recording MBID we already have on a MISSING row is that recording,
|
|
-- moved — not a new track.
|
|
--
|
|
-- `missing_since IS NOT NULL` is the safety constraint, not an optimisation: a
|
|
-- row whose file is present elsewhere on disk is a DUPLICATE, and re-pointing
|
|
-- its file_path would corrupt the copy that still exists.
|
|
--
|
|
-- LIMIT 2 because the caller only needs to know "exactly one" vs "more than
|
|
-- one" — an ambiguous match must not be adopted arbitrarily.
|
|
SELECT id, file_path FROM tracks
|
|
WHERE missing_since IS NOT NULL
|
|
AND mbid IS NOT NULL
|
|
AND mbid = sqlc.arg(mbid)::text
|
|
LIMIT 2;
|
|
|
|
-- name: FindMissingTrackByAudioHash :many
|
|
-- Move detection fallback for files with no MBID (#2528, #3914). The audio stream
|
|
-- hash identifies the encoded audio itself, so it survives a rename, a move and a
|
|
-- retag — anything short of a re-encode. It replaced (file_size, duration_ms),
|
|
-- which could pair two unrelated files that happened to share a byte count and a
|
|
-- duration, and missed a file retagged in place, whose size changes.
|
|
--
|
|
-- Same missing-only constraint and same LIMIT 2 rationale as the MBID variant.
|
|
SELECT t.id, t.file_path
|
|
FROM tracks t
|
|
JOIN track_fingerprints f ON f.track_id = t.id
|
|
WHERE t.missing_since IS NOT NULL
|
|
AND f.audio_stream_sha256 = sqlc.arg(audio_stream_sha256)
|
|
LIMIT 2;
|
|
|
|
-- name: AdoptTrackPath :execrows
|
|
-- Re-points a missing row at the path its file turned up on, and clears the
|
|
-- mark. The caller's normal UpsertTrack then conflicts on file_path and updates
|
|
-- THIS row in place, so the track id survives and its likes, play history and
|
|
-- playlist memberships come with it.
|
|
--
|
|
-- `missing_since IS NOT NULL` again, this time as a race guard: two files can't
|
|
-- both adopt the same row, and :execrows reports 0 to whichever loses.
|
|
UPDATE tracks
|
|
SET file_path = sqlc.arg(file_path),
|
|
missing_since = NULL
|
|
WHERE id = sqlc.arg(id)
|
|
AND missing_since IS NOT NULL;
|
|
|
|
-- name: ListTrackPathsForReconcile :many
|
|
-- Every row's path + current missing mark, for the scanner's reconcile pass
|
|
-- (#2523). Deliberately unfiltered and unpaged: reconcile has to compare the
|
|
-- WHOLE table against what the walk saw, and a filtered subset would let rows
|
|
-- outside it drift forever. Three narrow columns keep it cheap even on a
|
|
-- library of a few hundred thousand tracks.
|
|
SELECT id, file_path, missing_since FROM tracks;
|
|
|
|
-- name: MarkTracksMissing :execrows
|
|
-- Marks rows whose file the walk did not see. `missing_since IS NULL` in the
|
|
-- predicate makes this idempotent: a row already marked keeps its ORIGINAL
|
|
-- timestamp, so "how long has it been gone" survives repeated scans. Losing
|
|
-- that would make any age-based cleanup policy meaningless.
|
|
--
|
|
-- updated_at is deliberately NOT touched. It tracks content changes and gates
|
|
-- the scanner's mtime skip; moving it here would make a returning file look
|
|
-- newer than its own mtime and stop its tags being re-read.
|
|
UPDATE tracks
|
|
SET missing_since = now()
|
|
WHERE id = ANY(sqlc.arg(ids)::uuid[])
|
|
AND missing_since IS NULL;
|
|
|
|
-- name: ClearTracksMissing :execrows
|
|
-- Clears the mark on rows whose file is back. Runs independently of the mtime
|
|
-- skip check, so a file that reappears unchanged is un-marked even though the
|
|
-- scanner skips re-reading its tags.
|
|
UPDATE tracks
|
|
SET missing_since = NULL
|
|
WHERE id = ANY(sqlc.arg(ids)::uuid[])
|
|
AND missing_since IS NOT NULL;
|
|
|
|
-- name: ListMissingTracks :many
|
|
-- The admin review surface for files the scan could not find (#2527).
|
|
--
|
|
-- Ordered by directory, then by the file's own position within its album,
|
|
-- because the unit an operator actually reasons about is a FOLDER: the case
|
|
-- this was built for was three whole albums that had been reorganised, and a
|
|
-- flat list ordered by timestamp presents that as forty unrelated decisions.
|
|
-- Grouping happens in the handler; the ordering here is what makes a group
|
|
-- contiguous, so a page boundary splits a directory at worst.
|
|
--
|
|
-- last_played_at is a correlated MAX rather than a join so a track with no
|
|
-- plays stays in the result with NULL. It is here because "gone six months,
|
|
-- never played" and "gone yesterday, played 200 times" deserve opposite
|
|
-- reactions, and the operator can't tell them apart from a path.
|
|
SELECT t.id,
|
|
t.title,
|
|
t.file_path,
|
|
regexp_replace(t.file_path, '/[^/]*$', '') AS directory,
|
|
t.missing_since,
|
|
t.duration_ms,
|
|
albums.id AS album_id,
|
|
albums.title AS album_title,
|
|
artists.id AS artist_id,
|
|
artists.name AS artist_name,
|
|
-- Cast is load-bearing: without it sqlc infers the correlated
|
|
-- subquery as interface{} and the Go layer loses the timestamp type.
|
|
(SELECT MAX(pe.started_at) FROM play_events pe WHERE pe.track_id = t.id)::timestamptz AS last_played_at
|
|
FROM tracks t
|
|
JOIN albums ON albums.id = t.album_id
|
|
JOIN artists ON artists.id = t.artist_id
|
|
WHERE t.missing_since IS NOT NULL
|
|
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
|
|
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset);
|
|
|
|
-- name: CountMissingTracks :one
|
|
-- Total for the admin surface's badge and paging. Uses the same partial index
|
|
-- (tracks_missing_since_idx) as the list above.
|
|
SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL;
|
|
|
|
-- name: ListSuspectSourceTracks :many
|
|
-- The admin report of present tracks whose filename looks like a video rip
|
|
-- (#5410): "(Official Video)", "[Audio]", "Visualizer" and the like.
|
|
--
|
|
-- pattern is built in Go (internal/api/admin_suspect_sources.go) from the
|
|
-- same marker list that labels each row, so the filter and the labels cannot
|
|
-- drift. It is matched against the basename only: a folder called "Reaction
|
|
-- Sessions" says nothing about how a file was sourced.
|
|
--
|
|
-- Ordered by directory, like ListMissingTracks, so the handler's fold gets
|
|
-- each folder as one contiguous run.
|
|
SELECT t.id,
|
|
t.title,
|
|
t.file_path,
|
|
regexp_replace(t.file_path, '/[^/]*$', '') AS directory,
|
|
t.duration_ms,
|
|
t.disc_number,
|
|
t.track_number,
|
|
albums.id AS album_id,
|
|
albums.title AS album_title,
|
|
artists.id AS artist_id,
|
|
artists.name AS artist_name,
|
|
t.source_verdict,
|
|
t.source_verdict_at,
|
|
searched.at AS searched_at
|
|
FROM tracks t
|
|
JOIN albums ON albums.id = t.album_id
|
|
JOIN artists ON artists.id = t.artist_id
|
|
-- When the resolver last asked Lidarr to search the album for a better copy
|
|
-- (#5447).
|
|
LEFT JOIN LATERAL (
|
|
SELECT max(l.created_at)::timestamptz AS at FROM audit_log l
|
|
WHERE l.action = 'lidarr_rip_search' AND l.metadata->>'album_id' = albums.id::text
|
|
) searched ON true
|
|
WHERE t.missing_since IS NULL
|
|
AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text
|
|
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
|
|
LIMIT sqlc.arg(page_limit) OFFSET sqlc.arg(page_offset);
|
|
|
|
-- name: CountSuspectSourceTracks :one
|
|
-- Total for the report's paging; the same filter as the list above.
|
|
SELECT COUNT(*) FROM tracks t
|
|
WHERE t.missing_since IS NULL
|
|
AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
|
|
|
|
-- name: FlagSuspectSourceTracks :execrows
|
|
-- Holds back present tracks whose basename carries a video-rip marker (M498
|
|
-- #5439), the same pattern the report lists. A track the operator called fine
|
|
-- keeps that verdict.
|
|
UPDATE tracks
|
|
SET source_verdict = 'suspect', source_verdict_at = now()
|
|
WHERE missing_since IS NULL
|
|
AND source_verdict IS NULL
|
|
AND regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
|
|
|
|
-- name: ClearStaleSuspectSourceTracks :execrows
|
|
-- A held-back track whose name no longer carries a marker (renamed, retagged
|
|
-- by Lidarr) is released again.
|
|
UPDATE tracks
|
|
SET source_verdict = NULL, source_verdict_at = NULL
|
|
WHERE source_verdict = 'suspect'
|
|
AND NOT regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
|
|
|
|
-- name: SetTrackSourceVerdict :execrows
|
|
-- The operator's call on a flagged track: 'fine' lets it back into radio and
|
|
-- the mixes for good; 'suspect' holds it back again.
|
|
UPDATE tracks
|
|
SET source_verdict = sqlc.arg(verdict)::text, source_verdict_at = now()
|
|
WHERE id = sqlc.arg(id)
|
|
AND source_verdict IS NOT NULL;
|
|
|
|
-- name: ListAlbumsWithSoleCopyRips :many
|
|
-- Albums holding a held-back video rip that is the only copy of its song
|
|
-- (#5447): no present copy that is not a rip shares its song, and no pending
|
|
-- duplicate group pairs it with one. Lidarr counts such a track fulfilled, so
|
|
-- the resolver asks it to search the album for a better release. Albums
|
|
-- searched within the last week are left out, most rips first.
|
|
SELECT a.id AS album_id,
|
|
a.release_group_mbid::text AS release_group_mbid,
|
|
a.title AS album_title,
|
|
ar.name AS artist_name,
|
|
count(*)::bigint AS rips
|
|
FROM tracks t
|
|
JOIN albums a ON a.id = t.album_id
|
|
JOIN artists ar ON ar.id = a.artist_id
|
|
WHERE t.source_verdict = 'suspect'
|
|
AND t.missing_since IS NULL
|
|
AND a.release_group_mbid IS NOT NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM tracks c
|
|
WHERE c.song_key = t.song_key AND c.id <> t.id
|
|
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM duplicate_group_members m
|
|
JOIN duplicate_groups g ON g.id = m.group_id AND g.status = 'pending'
|
|
JOIN duplicate_group_members m2 ON m2.group_id = g.id AND m2.track_id <> t.id
|
|
JOIN tracks c ON c.id = m2.track_id
|
|
WHERE m.track_id = t.id
|
|
AND c.missing_since IS NULL AND c.source_verdict IS DISTINCT FROM 'suspect')
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM audit_log l
|
|
WHERE l.action = 'lidarr_rip_search'
|
|
AND l.metadata->>'album_id' = a.id::text
|
|
AND l.created_at > now() - interval '7 days')
|
|
GROUP BY a.id, a.release_group_mbid, a.title, ar.name
|
|
ORDER BY count(*) DESC, a.id
|
|
LIMIT sqlc.arg(page_limit);
|