Files
minstrel/internal/db/queries/tracks.sql
T
bvandeusen 4dd0a58d63
test-go / test (push) Successful in 52s
test-go / integration (push) Successful in 5m21s
feat(api): admin surface for files the library has lost — #2527
The scan has marked missing files since f6d1cf24 and every selection
path filters them out, so they cause no harm -- and are invisible. The
operator found out about the first batch only because an unrelated MBID
backfill logged "no such file or directory" forty times.

GET /api/admin/library/missing reports them, grouped by directory. The
grouping is the whole ergonomic argument: the case that produced #2523
was three reorganised albums, which a flat list renders as forty
unrelated problems and a folder list renders as three decisions.
ListMissingTracks orders by directory so the handler can fold runs
without a map, which also keeps the query's ordering instead of Go's
random map iteration.

Each row carries last_played_at, nullable, because "gone six months,
never played" and "gone yesterday, played 200 times" deserve opposite
reactions and a file path tells you neither. The correlated MAX needs
its ::timestamptz cast or sqlc infers interface{} and the Go layer
loses the type.

Read-only, deliberately. Nothing here deletes: a missing file keeps its
row, its play history and its likes because it may come back, and if it
comes back renamed the scanner adopts it (#2528). The route sits under
/library rather than /tracks so it can't be confused with the
destructive DELETE /admin/tracks/{id} beside it.
2026-08-16 11:48:45 -04:00

254 lines
10 KiB
SQL

-- name: UpsertTrack :one
-- file_path is the canonical identity for library scan; mbid is secondary.
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
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
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,
mbid = EXCLUDED.mbid,
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).
SELECT id, file_path
FROM tracks
WHERE mbid 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, 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: FindMissingTrackByFingerprint :many
-- Move detection fallback for files with no MBID (#2528). Exact byte size AND
-- exact decoded duration is a strong pair: a plain move or rename preserves
-- both, while a re-encode changes at least one — and a re-encode genuinely is a
-- different file, so failing to match there is correct rather than a gap.
--
-- Same missing-only constraint and same LIMIT 2 rationale as the MBID variant.
SELECT id, file_path FROM tracks
WHERE missing_since IS NOT NULL
AND file_size = sqlc.arg(file_size)
AND duration_ms = sqlc.arg(duration_ms)
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;