Files
minstrel/internal/db/queries/albums.sql
T
bvandeusen c7549bbe48 perf(api): collapse N+1 in /api/artists/{id} + 1 round-trip in /api/albums/{id}
Two new sqlc queries replace three sequential per-album round trips
that were dominating detail-screen latency.

GetAlbumWithArtist: handleGetAlbum was doing GetAlbumByID then
GetArtistByID — separate round trips for one logical lookup. The new
query joins albums + artists with sqlc.embed and returns both in one
SELECT. Detail-page DB cost: 3 trips → 2.

ListAlbumsByArtistWithTrackCount: handleGetArtist was loading the
artist's album list, then issuing one CountTracksByAlbum per album to
populate track_count. On a 30-album artist that's 32 sequential
queries — each ~5ms over a local DB, ~30ms over a remote one. The
new query embeds the album row + a correlated count(*) subquery, so
every album's track count comes back in one SELECT regardless of
album count. Detail-page DB cost: 1 + N → 1 + 1.

Together these account for the bulk of cold-cache navigation latency
on the Flutter client. Combined with the existing SWR + nav
hydration on the client side, detail screens should render their
header instantly and the body within one round trip instead of
N+constant.
2026-05-11 18:13:27 -04:00

187 lines
7.1 KiB
SQL

-- name: UpsertAlbum :one
INSERT INTO albums (title, sort_title, artist_id, release_date, mbid, cover_art_path)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (mbid) WHERE mbid IS NOT NULL
DO UPDATE SET
title = EXCLUDED.title,
sort_title = EXCLUDED.sort_title,
artist_id = EXCLUDED.artist_id,
release_date = EXCLUDED.release_date,
cover_art_path = EXCLUDED.cover_art_path,
updated_at = now()
RETURNING *;
-- name: GetAlbumByID :one
SELECT * FROM albums WHERE id = $1;
-- name: GetAlbumWithArtist :one
-- Combined fetch for /api/albums/{id}: returns the album row + the
-- joined artist name in a single round trip. Replaces a sequential
-- GetAlbumByID + GetArtistByID pair on the hot detail-page path.
SELECT sqlc.embed(albums), artists.name AS artist_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
WHERE albums.id = $1;
-- name: ListAlbumsByArtistWithTrackCount :many
-- Replaces the N+1 pattern in handleGetArtist (1 + N CountTracksByAlbum
-- per album). Returns each album joined with its track count via a
-- correlated subquery — single round trip regardless of album count.
SELECT sqlc.embed(albums),
(SELECT count(*) FROM tracks t WHERE t.album_id = albums.id)::bigint
AS track_count
FROM albums
WHERE albums.artist_id = $1
ORDER BY release_date NULLS LAST, sort_title;
-- name: GetAlbumByArtistAndTitle :one
-- Scanner uses this for the no-mbid dedupe path: resolve-or-create.
SELECT * FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1;
-- name: ListAlbumsByArtist :many
SELECT * FROM albums WHERE artist_id = $1 ORDER BY release_date NULLS LAST, sort_title;
-- name: ListAlbumsAlphaByName :many
SELECT * FROM albums ORDER BY sort_title LIMIT $1 OFFSET $2;
-- name: ListAlbumsAlphaByArtist :many
-- Sorted by the owning artist's sort_name. Needed by Subsonic's
-- alphabeticalByArtist album list type.
SELECT sqlc.embed(albums), artists.sort_name AS artist_sort_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
ORDER BY artists.sort_name, albums.sort_title
LIMIT $1 OFFSET $2;
-- name: ListAlbumsNewest :many
SELECT * FROM albums ORDER BY created_at DESC LIMIT $1 OFFSET $2;
-- name: ListAlbumsRandom :many
SELECT * FROM albums ORDER BY random() LIMIT $1;
-- name: ListAlbumsByGenre :many
-- Album "belongs to" a genre if any of its tracks carry that genre.
SELECT DISTINCT ON (albums.id) albums.*
FROM albums
JOIN tracks ON tracks.album_id = albums.id
WHERE tracks.genre = $1
ORDER BY albums.id, albums.sort_title
LIMIT $2 OFFSET $3;
-- name: SearchAlbums :many
SELECT * FROM albums
WHERE title ILIKE '%' || $1 || '%'
ORDER BY sort_title
LIMIT $2 OFFSET $3;
-- name: CountAlbumsMatching :one
SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%';
-- name: ListRecentlyAddedAlbumsWithArtist :many
-- M6a: recently-added albums joined with artist_name + artist_id for the
-- home-page section. created_at is the row-insert timestamp from the
-- scanner — the closest proxy to "added to library". Stable secondary
-- ordering by id keeps pagination tie-break deterministic.
SELECT sqlc.embed(albums), artists.name AS artist_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
ORDER BY albums.created_at DESC, albums.id
LIMIT $1;
-- name: ListAlbumsAlphaWithArtist :many
-- M6a: alpha-sorted album list joined with artist_name. Used by
-- /api/library/albums for the wrapping-grid page. Stable id-tiebreak.
SELECT sqlc.embed(albums), artists.name AS artist_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
ORDER BY albums.sort_title, albums.id
LIMIT $1 OFFSET $2;
-- name: CountAlbums :one
-- M6a: total album count for the /api/library/albums envelope.
SELECT COUNT(*) FROM albums;
-- name: CountAlbumsByArtist :one
-- Used by request-progress reporting to count how many albums have been
-- ingested for a matched artist while a request is still in flight.
SELECT COUNT(*) FROM albums WHERE artist_id = $1;
-- name: DeleteAlbumIfEmpty :one
-- M7 #372: deletes the album row only when it has no remaining tracks.
-- Used after a track delete to tidy up orphaned albums. RETURNING gives
-- us the artist_id so the service can chain into the artist-empty check.
-- Returns no rows when the album still has tracks; the service treats
-- pgx.ErrNoRows as "album not orphaned, skip cascade".
DELETE FROM albums a
WHERE a.id = $1
AND NOT EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id)
RETURNING a.id, a.artist_id;
-- name: SetAlbumMbidIfNull :exec
-- M7 #379: heal MBID on existing album rows during rescan. Idempotent —
-- only updates when the existing mbid is NULL.
UPDATE albums
SET mbid = $2,
updated_at = now()
WHERE id = $1 AND mbid IS NULL;
-- name: ListAlbumsMissingMbidWithTrack :many
-- One-shot MBID backfill: returns each album where mbid IS NULL alongside
-- one of its tracks' file_path so the worker can re-read tags. LIMIT
-- supplied by caller for batching/progress purposes.
SELECT a.id AS album_id,
a.artist_id AS artist_id,
a.title AS title,
t.file_path AS track_file_path
FROM albums a
JOIN LATERAL (
SELECT file_path
FROM tracks
WHERE album_id = a.id
ORDER BY disc_number NULLS LAST, track_number NULLS LAST, id
LIMIT 1
) t ON true
WHERE a.mbid IS NULL
ORDER BY a.created_at ASC
LIMIT $1;
-- name: GetAlbumCoverageRollup :one
-- M7 coverage-gauge: library-wide cover-art coverage rollup for the
-- admin gauge. Single sequential scan with FILTER aggregates —
-- sub-millisecond on realistic libraries. pending_no_mbid is a SUBSET
-- of pending; the UI surfaces it separately so operators can see how
-- many "pending" albums are blocked on missing MBID and won't be
-- helped by another scan.
--
-- Invariant: with_art + pending + settled = total. (pending_no_mbid is
-- not part of the sum — it's a subset of pending, surfaced separately.)
--
-- The IN list below ('sidecar','embedded','mbcaa','theaudiodb') must stay in sync
-- with the cover_art_source CHECK constraint in migration
-- 0016_album_cover_source.up.sql. If a new source value is added there
-- without updating this query, with_art will silently undercount.
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE cover_art_source IN ('sidecar','embedded','mbcaa','theaudiodb')) AS with_art,
COUNT(*) FILTER (WHERE cover_art_source IS NULL) AS pending,
COUNT(*) FILTER (WHERE cover_art_source = 'none') AS settled,
COUNT(*) FILTER (WHERE cover_art_source IS NULL AND mbid IS NULL) AS pending_no_mbid
FROM albums;
-- name: SetAlbumCoverWithVersion :exec
-- M7 cover-sources: records a successful or settled enrichment with the
-- current sources_version stamp. cover_art_path is NULL when source is
-- 'none' (no file written). Atomic; called per album per pass.
UPDATE albums
SET cover_art_path = $2,
cover_art_source = $3,
cover_art_sources_version = $4,
updated_at = now()
WHERE id = $1;
-- name: GetAlbumsByIDs :many
-- Batched lookup used by /api/library/sync to hydrate upsert payloads
-- (#357). Mirror of GetArtistsByIDs.
SELECT * FROM albums WHERE id = ANY($1::uuid[]);