-- 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;