release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m37s
release / go (push) Successful in 1m45s
release / integration (push) Failing after 5m27s
release / android (push) Successful in 5m40s
release / Build signed APK (releases and dev) (push) Successful in 5m55s
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
type=recent orders the caller's albums by their latest play (skips included); type=frequent orders them by completed plays (skips excluded). Plays of tracks whose file is gone do not count, and both page by size/offset like the other types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
296 lines
12 KiB
SQL
296 lines
12 KiB
SQL
-- name: UpsertAlbum :one
|
|
INSERT INTO albums (title, sort_title, artist_id, release_date, mbid, cover_art_path, release_group_mbid)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
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,
|
|
release_group_mbid = COALESCE(EXCLUDED.release_group_mbid, albums.release_group_mbid),
|
|
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. An album
|
|
-- of the same name in another release group is a different album (one
|
|
-- artist's self-titled albums), so a row whose
|
|
-- group is known and differs is not returned. A row of the file's own group
|
|
-- comes first, then one with no group yet, then the one holding the most
|
|
-- tracks from the file's folder: a file tagged with no group (a live bonus
|
|
-- track beside its album) belongs with its folder, not the oldest
|
|
-- row, and a few strays already misfiled do not outvote the folder.
|
|
SELECT albums.* FROM albums
|
|
WHERE albums.artist_id = sqlc.arg(artist_id) AND albums.title = sqlc.arg(title)
|
|
AND (sqlc.narg(release_group_mbid)::text IS NULL OR albums.release_group_mbid IS NULL
|
|
OR albums.release_group_mbid = sqlc.narg(release_group_mbid))
|
|
ORDER BY (albums.release_group_mbid IS NOT DISTINCT FROM sqlc.narg(release_group_mbid)) DESC,
|
|
(SELECT count(*) FROM tracks t
|
|
WHERE t.album_id = albums.id AND starts_with(t.file_path, sqlc.arg(dir)::text || '/')) DESC,
|
|
albums.id
|
|
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.
|
|
-- Serves Subsonic getAlbumList?type=byGenre.
|
|
--
|
|
-- Splits tracks.genre on [;,], so a track tagged "Rock;Pop" is reachable from
|
|
-- both "Rock" and "Pop"; comparing the whole column would hide every
|
|
-- multi-genre track from a Subsonic client asking for a genre. The same split
|
|
-- as recommendation.sql, discover.sql and the genre browse index.
|
|
--
|
|
-- EXISTS rather than JOIN + DISTINCT ON: the lateral split emits one row per
|
|
-- (track, genre-fragment), so a join would multiply rows per album and lean
|
|
-- on DISTINCT to undo it. EXISTS asks the question directly.
|
|
SELECT albums.*
|
|
FROM albums
|
|
WHERE EXISTS (
|
|
SELECT 1
|
|
FROM tracks
|
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
|
WHERE tracks.album_id = albums.id
|
|
AND trim(g.genre) = trim(sqlc.arg(genre)::text)
|
|
)
|
|
ORDER BY albums.sort_title, albums.id
|
|
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
|
|
|
|
-- name: ListAlbumsRecentlyPlayedByUser :many
|
|
-- The user's albums, most recently played first: an album's time is its
|
|
-- latest play of any of its tracks, skips included, since a skip is still
|
|
-- the user reaching for that album. Plays of tracks whose file is gone do
|
|
-- not count. Serves Subsonic getAlbumList2?type=recent.
|
|
--
|
|
-- Aggregates play_events per track first (play_events_user_track_idx), then
|
|
-- folds tracks into albums, so the join runs over distinct tracks rather
|
|
-- than every play.
|
|
WITH plays AS (
|
|
SELECT track_id, max(started_at) AS last_played
|
|
FROM play_events
|
|
WHERE user_id = sqlc.arg(user_id)
|
|
GROUP BY track_id
|
|
), per_album AS (
|
|
SELECT t.album_id, max(p.last_played) AS last_played
|
|
FROM plays p
|
|
JOIN tracks t ON t.id = p.track_id
|
|
WHERE t.missing_since IS NULL
|
|
GROUP BY t.album_id
|
|
)
|
|
SELECT albums.*
|
|
FROM per_album pa
|
|
JOIN albums ON albums.id = pa.album_id
|
|
ORDER BY pa.last_played DESC, albums.id
|
|
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
|
|
|
|
-- name: ListAlbumsFrequentlyPlayedByUser :many
|
|
-- The user's albums, most played first: an album's count is the user's
|
|
-- completed plays across its tracks. Skips do not count, so skipping
|
|
-- through an album does not make it a favourite. Plays of tracks whose file
|
|
-- is gone do not count. Serves Subsonic getAlbumList2?type=frequent.
|
|
WITH plays AS (
|
|
SELECT track_id, count(*) AS cnt
|
|
FROM play_events
|
|
WHERE user_id = sqlc.arg(user_id) AND was_skipped = false
|
|
GROUP BY track_id
|
|
), per_album AS (
|
|
SELECT t.album_id, sum(p.cnt) AS cnt
|
|
FROM plays p
|
|
JOIN tracks t ON t.id = p.track_id
|
|
WHERE t.missing_since IS NULL
|
|
GROUP BY t.album_id
|
|
)
|
|
SELECT albums.*
|
|
FROM per_album pa
|
|
JOIN albums ON albums.id = pa.album_id
|
|
ORDER BY pa.cnt DESC, albums.id
|
|
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
|
|
|
|
-- 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
|
|
-- 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
|
|
-- 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
|
|
-- 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
|
|
-- 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
|
|
-- 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: SetAlbumReleaseGroupMbidIfNull :exec
|
|
-- Heal the release-group id on an existing album row, from a rescan's
|
|
-- tags or from the re-acquisition sweeper's MusicBrainz lookup. Only fills a
|
|
-- NULL, so a tag never gets overwritten by a later guess or vice versa.
|
|
UPDATE albums
|
|
SET release_group_mbid = $2,
|
|
updated_at = now()
|
|
WHERE id = $1 AND release_group_mbid IS NULL;
|
|
|
|
-- name: GetReleaseGroupForReleaseMbid :one
|
|
-- The release group the library already knows for a release id, so a
|
|
-- request carrying a release id can be repaired without asking MusicBrainz.
|
|
SELECT release_group_mbid::text
|
|
FROM albums
|
|
WHERE mbid = $1 AND release_group_mbid IS NOT NULL
|
|
LIMIT 1;
|
|
|
|
-- name: SetReleaseGroupForReleaseMbidIfNull :exec
|
|
-- Cache a MusicBrainz-resolved release group onto the album row that
|
|
-- holds the release, filling only a NULL.
|
|
UPDATE albums
|
|
SET release_group_mbid = sqlc.arg(release_group_mbid)::text,
|
|
updated_at = now()
|
|
WHERE mbid = sqlc.arg(release_mbid)::text AND release_group_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. The track read is
|
|
-- one still on disk; an album with none left is skipped until a scan
|
|
-- finds its files again.
|
|
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
|
|
AND missing_since IS NULL
|
|
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
|
|
-- 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 must name every source the enrichers write (the
|
|
-- cover_art_source CHECK accepts any non-empty string since migration
|
|
-- 0030). A source missing here makes with_art silently undercount.
|
|
SELECT
|
|
COUNT(*) AS total,
|
|
COUNT(*) FILTER (WHERE cover_art_source IN
|
|
('sidecar','embedded','mbcaa','theaudiodb','deezer','lastfm')) 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
|
|
-- 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
|
|
--. Mirror of GetArtistsByIDs.
|
|
SELECT * FROM albums WHERE id = ANY($1::uuid[]);
|