47d2f61161
Six findings from the 2026-06-02 multi-system drift audit (Scribe parent task #552): - **#553 (web)** Tailwind class fix: web admin playback-errors Delete confirm button was using `bg-action-danger`, an undefined token — swap to `bg-action-destructive` to match every other destructive button. Restored the Oxblood signal that distinguishes Delete from Cancel. - **#555 (web)** Type the `source` field on `play_started` in the EventRequest discriminated union. Server's eventRequest accepts it; web's TS type was missing the slot, so a "drop extra properties" refactor could silently strip the source tag and break system- playlist rotation attribution. - **#556 + #557 (server)** Coverage rollup whitelist was pinned to ('sidecar','embedded','mbcaa','theaudiodb'); migration 0020 added 'deezer' and 'lastfm' as valid cover_art_source values but those never got wired in, so albums with art from those providers silently counted as MISSING in the admin Coverage dashboard. The rollup test was seeding only the pre-0020 sources, masking the gap in CI. Extend the query to include deezer + lastfm; seed the test with one row per valid source (regression-guards future additions). - **#558 (web)** Auth gate was blocking /forgot-password and /reset-password/<token> — both are entered without a session by definition, so the email-link reset flow was bouncing signed-out users to /login. Add /forgot-password to the public set and a /reset-password/ prefix matcher. New tests assert both routes reach their pages without redirect. - **#571 (server)** Library scanner was indexing only .mp3/.m4a/.flac /.ogg while the stream handler (media.go) had been extended to serve .opus, .aac, and .wav. A user with .opus files in their library never saw them in artist/album listings because the scanner skipped indexing — silent data loss. Aligned the scanner to match the media handler. Scribe statuses updated to in_progress; flipping to done after the push since these are mechanical and verified directly against the cited file:lines.
849 lines
24 KiB
Go
849 lines
24 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: albums.sql
|
|
|
|
package dbq
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const countAlbums = `-- name: CountAlbums :one
|
|
SELECT COUNT(*) FROM albums
|
|
`
|
|
|
|
// M6a: total album count for the /api/library/albums envelope.
|
|
func (q *Queries) CountAlbums(ctx context.Context) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countAlbums)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const countAlbumsByArtist = `-- name: CountAlbumsByArtist :one
|
|
SELECT COUNT(*) FROM albums WHERE artist_id = $1
|
|
`
|
|
|
|
// Used by request-progress reporting to count how many albums have been
|
|
// ingested for a matched artist while a request is still in flight.
|
|
func (q *Queries) CountAlbumsByArtist(ctx context.Context, artistID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countAlbumsByArtist, artistID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const countAlbumsMatching = `-- name: CountAlbumsMatching :one
|
|
SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%'
|
|
`
|
|
|
|
func (q *Queries) CountAlbumsMatching(ctx context.Context, dollar_1 string) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countAlbumsMatching, dollar_1)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const deleteAlbumIfEmpty = `-- name: DeleteAlbumIfEmpty :one
|
|
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
|
|
`
|
|
|
|
type DeleteAlbumIfEmptyRow struct {
|
|
ID pgtype.UUID
|
|
ArtistID pgtype.UUID
|
|
}
|
|
|
|
// 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".
|
|
func (q *Queries) DeleteAlbumIfEmpty(ctx context.Context, id pgtype.UUID) (DeleteAlbumIfEmptyRow, error) {
|
|
row := q.db.QueryRow(ctx, deleteAlbumIfEmpty, id)
|
|
var i DeleteAlbumIfEmptyRow
|
|
err := row.Scan(&i.ID, &i.ArtistID)
|
|
return i, err
|
|
}
|
|
|
|
const getAlbumByArtistAndTitle = `-- name: GetAlbumByArtistAndTitle :one
|
|
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1
|
|
`
|
|
|
|
type GetAlbumByArtistAndTitleParams struct {
|
|
ArtistID pgtype.UUID
|
|
Title string
|
|
}
|
|
|
|
// Scanner uses this for the no-mbid dedupe path: resolve-or-create.
|
|
func (q *Queries) GetAlbumByArtistAndTitle(ctx context.Context, arg GetAlbumByArtistAndTitleParams) (Album, error) {
|
|
row := q.db.QueryRow(ctx, getAlbumByArtistAndTitle, arg.ArtistID, arg.Title)
|
|
var i Album
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.SortTitle,
|
|
&i.ArtistID,
|
|
&i.ReleaseDate,
|
|
&i.Mbid,
|
|
&i.CoverArtPath,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.CoverArtSource,
|
|
&i.CoverArtSourcesVersion,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getAlbumByID = `-- name: GetAlbumByID :one
|
|
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE id = $1
|
|
`
|
|
|
|
func (q *Queries) GetAlbumByID(ctx context.Context, id pgtype.UUID) (Album, error) {
|
|
row := q.db.QueryRow(ctx, getAlbumByID, id)
|
|
var i Album
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.SortTitle,
|
|
&i.ArtistID,
|
|
&i.ReleaseDate,
|
|
&i.Mbid,
|
|
&i.CoverArtPath,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.CoverArtSource,
|
|
&i.CoverArtSourcesVersion,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getAlbumCoverageRollup = `-- name: GetAlbumCoverageRollup :one
|
|
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
|
|
`
|
|
|
|
type GetAlbumCoverageRollupRow struct {
|
|
Total int64
|
|
WithArt int64
|
|
Pending int64
|
|
Settled int64
|
|
PendingNoMbid int64
|
|
}
|
|
|
|
// 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 must stay in sync with the cover_art_source CHECK
|
|
// constraint — currently relaxed by migration 0020 to include 'deezer'
|
|
// and 'lastfm', and migration 0030 further relaxed it to any non-empty
|
|
// string. If a new source value is added without updating this query,
|
|
// with_art will silently undercount. Drift #556 caught the deezer +
|
|
// lastfm omission introduced by migration 0020.
|
|
func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageRollupRow, error) {
|
|
row := q.db.QueryRow(ctx, getAlbumCoverageRollup)
|
|
var i GetAlbumCoverageRollupRow
|
|
err := row.Scan(
|
|
&i.Total,
|
|
&i.WithArt,
|
|
&i.Pending,
|
|
&i.Settled,
|
|
&i.PendingNoMbid,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getAlbumWithArtist = `-- name: GetAlbumWithArtist :one
|
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
|
FROM albums
|
|
JOIN artists ON artists.id = albums.artist_id
|
|
WHERE albums.id = $1
|
|
`
|
|
|
|
type GetAlbumWithArtistRow struct {
|
|
Album Album
|
|
ArtistName string
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) GetAlbumWithArtist(ctx context.Context, id pgtype.UUID) (GetAlbumWithArtistRow, error) {
|
|
row := q.db.QueryRow(ctx, getAlbumWithArtist, id)
|
|
var i GetAlbumWithArtistRow
|
|
err := row.Scan(
|
|
&i.Album.ID,
|
|
&i.Album.Title,
|
|
&i.Album.SortTitle,
|
|
&i.Album.ArtistID,
|
|
&i.Album.ReleaseDate,
|
|
&i.Album.Mbid,
|
|
&i.Album.CoverArtPath,
|
|
&i.Album.CreatedAt,
|
|
&i.Album.UpdatedAt,
|
|
&i.Album.CoverArtSource,
|
|
&i.Album.CoverArtSourcesVersion,
|
|
&i.ArtistName,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getAlbumsByIDs = `-- name: GetAlbumsByIDs :many
|
|
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE id = ANY($1::uuid[])
|
|
`
|
|
|
|
// Batched lookup used by /api/library/sync to hydrate upsert payloads
|
|
// (#357). Mirror of GetArtistsByIDs.
|
|
func (q *Queries) GetAlbumsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Album, error) {
|
|
rows, err := q.db.Query(ctx, getAlbumsByIDs, dollar_1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []Album
|
|
for rows.Next() {
|
|
var i Album
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.SortTitle,
|
|
&i.ArtistID,
|
|
&i.ReleaseDate,
|
|
&i.Mbid,
|
|
&i.CoverArtPath,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.CoverArtSource,
|
|
&i.CoverArtSourcesVersion,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAlbumsAlphaByArtist = `-- name: ListAlbumsAlphaByArtist :many
|
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, 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
|
|
`
|
|
|
|
type ListAlbumsAlphaByArtistParams struct {
|
|
Limit int32
|
|
Offset int32
|
|
}
|
|
|
|
type ListAlbumsAlphaByArtistRow struct {
|
|
Album Album
|
|
ArtistSortName string
|
|
}
|
|
|
|
// Sorted by the owning artist's sort_name. Needed by Subsonic's
|
|
// alphabeticalByArtist album list type.
|
|
func (q *Queries) ListAlbumsAlphaByArtist(ctx context.Context, arg ListAlbumsAlphaByArtistParams) ([]ListAlbumsAlphaByArtistRow, error) {
|
|
rows, err := q.db.Query(ctx, listAlbumsAlphaByArtist, arg.Limit, arg.Offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListAlbumsAlphaByArtistRow
|
|
for rows.Next() {
|
|
var i ListAlbumsAlphaByArtistRow
|
|
if err := rows.Scan(
|
|
&i.Album.ID,
|
|
&i.Album.Title,
|
|
&i.Album.SortTitle,
|
|
&i.Album.ArtistID,
|
|
&i.Album.ReleaseDate,
|
|
&i.Album.Mbid,
|
|
&i.Album.CoverArtPath,
|
|
&i.Album.CreatedAt,
|
|
&i.Album.UpdatedAt,
|
|
&i.Album.CoverArtSource,
|
|
&i.Album.CoverArtSourcesVersion,
|
|
&i.ArtistSortName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAlbumsAlphaByName = `-- name: ListAlbumsAlphaByName :many
|
|
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums ORDER BY sort_title LIMIT $1 OFFSET $2
|
|
`
|
|
|
|
type ListAlbumsAlphaByNameParams struct {
|
|
Limit int32
|
|
Offset int32
|
|
}
|
|
|
|
func (q *Queries) ListAlbumsAlphaByName(ctx context.Context, arg ListAlbumsAlphaByNameParams) ([]Album, error) {
|
|
rows, err := q.db.Query(ctx, listAlbumsAlphaByName, arg.Limit, arg.Offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []Album
|
|
for rows.Next() {
|
|
var i Album
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.SortTitle,
|
|
&i.ArtistID,
|
|
&i.ReleaseDate,
|
|
&i.Mbid,
|
|
&i.CoverArtPath,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.CoverArtSource,
|
|
&i.CoverArtSourcesVersion,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAlbumsAlphaWithArtist = `-- name: ListAlbumsAlphaWithArtist :many
|
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, 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
|
|
`
|
|
|
|
type ListAlbumsAlphaWithArtistParams struct {
|
|
Limit int32
|
|
Offset int32
|
|
}
|
|
|
|
type ListAlbumsAlphaWithArtistRow struct {
|
|
Album Album
|
|
ArtistName string
|
|
}
|
|
|
|
// M6a: alpha-sorted album list joined with artist_name. Used by
|
|
// /api/library/albums for the wrapping-grid page. Stable id-tiebreak.
|
|
func (q *Queries) ListAlbumsAlphaWithArtist(ctx context.Context, arg ListAlbumsAlphaWithArtistParams) ([]ListAlbumsAlphaWithArtistRow, error) {
|
|
rows, err := q.db.Query(ctx, listAlbumsAlphaWithArtist, arg.Limit, arg.Offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListAlbumsAlphaWithArtistRow
|
|
for rows.Next() {
|
|
var i ListAlbumsAlphaWithArtistRow
|
|
if err := rows.Scan(
|
|
&i.Album.ID,
|
|
&i.Album.Title,
|
|
&i.Album.SortTitle,
|
|
&i.Album.ArtistID,
|
|
&i.Album.ReleaseDate,
|
|
&i.Album.Mbid,
|
|
&i.Album.CoverArtPath,
|
|
&i.Album.CreatedAt,
|
|
&i.Album.UpdatedAt,
|
|
&i.Album.CoverArtSource,
|
|
&i.Album.CoverArtSourcesVersion,
|
|
&i.ArtistName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAlbumsByArtist = `-- name: ListAlbumsByArtist :many
|
|
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums WHERE artist_id = $1 ORDER BY release_date NULLS LAST, sort_title
|
|
`
|
|
|
|
func (q *Queries) ListAlbumsByArtist(ctx context.Context, artistID pgtype.UUID) ([]Album, error) {
|
|
rows, err := q.db.Query(ctx, listAlbumsByArtist, artistID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []Album
|
|
for rows.Next() {
|
|
var i Album
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.SortTitle,
|
|
&i.ArtistID,
|
|
&i.ReleaseDate,
|
|
&i.Mbid,
|
|
&i.CoverArtPath,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.CoverArtSource,
|
|
&i.CoverArtSourcesVersion,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAlbumsByArtistWithTrackCount = `-- name: ListAlbumsByArtistWithTrackCount :many
|
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version,
|
|
(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
|
|
`
|
|
|
|
type ListAlbumsByArtistWithTrackCountRow struct {
|
|
Album Album
|
|
TrackCount int64
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) ListAlbumsByArtistWithTrackCount(ctx context.Context, artistID pgtype.UUID) ([]ListAlbumsByArtistWithTrackCountRow, error) {
|
|
rows, err := q.db.Query(ctx, listAlbumsByArtistWithTrackCount, artistID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListAlbumsByArtistWithTrackCountRow
|
|
for rows.Next() {
|
|
var i ListAlbumsByArtistWithTrackCountRow
|
|
if err := rows.Scan(
|
|
&i.Album.ID,
|
|
&i.Album.Title,
|
|
&i.Album.SortTitle,
|
|
&i.Album.ArtistID,
|
|
&i.Album.ReleaseDate,
|
|
&i.Album.Mbid,
|
|
&i.Album.CoverArtPath,
|
|
&i.Album.CreatedAt,
|
|
&i.Album.UpdatedAt,
|
|
&i.Album.CoverArtSource,
|
|
&i.Album.CoverArtSourcesVersion,
|
|
&i.TrackCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many
|
|
SELECT DISTINCT ON (albums.id) albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version
|
|
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
|
|
`
|
|
|
|
type ListAlbumsByGenreParams struct {
|
|
Genre *string
|
|
Limit int32
|
|
Offset int32
|
|
}
|
|
|
|
// Album "belongs to" a genre if any of its tracks carry that genre.
|
|
func (q *Queries) ListAlbumsByGenre(ctx context.Context, arg ListAlbumsByGenreParams) ([]Album, error) {
|
|
rows, err := q.db.Query(ctx, listAlbumsByGenre, arg.Genre, arg.Limit, arg.Offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []Album
|
|
for rows.Next() {
|
|
var i Album
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.SortTitle,
|
|
&i.ArtistID,
|
|
&i.ReleaseDate,
|
|
&i.Mbid,
|
|
&i.CoverArtPath,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.CoverArtSource,
|
|
&i.CoverArtSourcesVersion,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAlbumsMissingMbidWithTrack = `-- name: ListAlbumsMissingMbidWithTrack :many
|
|
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
|
|
`
|
|
|
|
type ListAlbumsMissingMbidWithTrackRow struct {
|
|
AlbumID pgtype.UUID
|
|
ArtistID pgtype.UUID
|
|
Title string
|
|
TrackFilePath string
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) ListAlbumsMissingMbidWithTrack(ctx context.Context, limit int32) ([]ListAlbumsMissingMbidWithTrackRow, error) {
|
|
rows, err := q.db.Query(ctx, listAlbumsMissingMbidWithTrack, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListAlbumsMissingMbidWithTrackRow
|
|
for rows.Next() {
|
|
var i ListAlbumsMissingMbidWithTrackRow
|
|
if err := rows.Scan(
|
|
&i.AlbumID,
|
|
&i.ArtistID,
|
|
&i.Title,
|
|
&i.TrackFilePath,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAlbumsNewest = `-- name: ListAlbumsNewest :many
|
|
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums ORDER BY created_at DESC LIMIT $1 OFFSET $2
|
|
`
|
|
|
|
type ListAlbumsNewestParams struct {
|
|
Limit int32
|
|
Offset int32
|
|
}
|
|
|
|
func (q *Queries) ListAlbumsNewest(ctx context.Context, arg ListAlbumsNewestParams) ([]Album, error) {
|
|
rows, err := q.db.Query(ctx, listAlbumsNewest, arg.Limit, arg.Offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []Album
|
|
for rows.Next() {
|
|
var i Album
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.SortTitle,
|
|
&i.ArtistID,
|
|
&i.ReleaseDate,
|
|
&i.Mbid,
|
|
&i.CoverArtPath,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.CoverArtSource,
|
|
&i.CoverArtSourcesVersion,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAlbumsRandom = `-- name: ListAlbumsRandom :many
|
|
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums ORDER BY random() LIMIT $1
|
|
`
|
|
|
|
func (q *Queries) ListAlbumsRandom(ctx context.Context, limit int32) ([]Album, error) {
|
|
rows, err := q.db.Query(ctx, listAlbumsRandom, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []Album
|
|
for rows.Next() {
|
|
var i Album
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.SortTitle,
|
|
&i.ArtistID,
|
|
&i.ReleaseDate,
|
|
&i.Mbid,
|
|
&i.CoverArtPath,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.CoverArtSource,
|
|
&i.CoverArtSourcesVersion,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listRecentlyAddedAlbumsWithArtist = `-- name: ListRecentlyAddedAlbumsWithArtist :many
|
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, 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
|
|
`
|
|
|
|
type ListRecentlyAddedAlbumsWithArtistRow struct {
|
|
Album Album
|
|
ArtistName string
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) ListRecentlyAddedAlbumsWithArtist(ctx context.Context, limit int32) ([]ListRecentlyAddedAlbumsWithArtistRow, error) {
|
|
rows, err := q.db.Query(ctx, listRecentlyAddedAlbumsWithArtist, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListRecentlyAddedAlbumsWithArtistRow
|
|
for rows.Next() {
|
|
var i ListRecentlyAddedAlbumsWithArtistRow
|
|
if err := rows.Scan(
|
|
&i.Album.ID,
|
|
&i.Album.Title,
|
|
&i.Album.SortTitle,
|
|
&i.Album.ArtistID,
|
|
&i.Album.ReleaseDate,
|
|
&i.Album.Mbid,
|
|
&i.Album.CoverArtPath,
|
|
&i.Album.CreatedAt,
|
|
&i.Album.UpdatedAt,
|
|
&i.Album.CoverArtSource,
|
|
&i.Album.CoverArtSourcesVersion,
|
|
&i.ArtistName,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const searchAlbums = `-- name: SearchAlbums :many
|
|
SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version FROM albums
|
|
WHERE title ILIKE '%' || $1 || '%'
|
|
ORDER BY sort_title
|
|
LIMIT $2 OFFSET $3
|
|
`
|
|
|
|
type SearchAlbumsParams struct {
|
|
Column1 *string
|
|
Limit int32
|
|
Offset int32
|
|
}
|
|
|
|
func (q *Queries) SearchAlbums(ctx context.Context, arg SearchAlbumsParams) ([]Album, error) {
|
|
rows, err := q.db.Query(ctx, searchAlbums, arg.Column1, arg.Limit, arg.Offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []Album
|
|
for rows.Next() {
|
|
var i Album
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.SortTitle,
|
|
&i.ArtistID,
|
|
&i.ReleaseDate,
|
|
&i.Mbid,
|
|
&i.CoverArtPath,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.CoverArtSource,
|
|
&i.CoverArtSourcesVersion,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const setAlbumCoverWithVersion = `-- name: SetAlbumCoverWithVersion :exec
|
|
UPDATE albums
|
|
SET cover_art_path = $2,
|
|
cover_art_source = $3,
|
|
cover_art_sources_version = $4,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
`
|
|
|
|
type SetAlbumCoverWithVersionParams struct {
|
|
ID pgtype.UUID
|
|
CoverArtPath *string
|
|
CoverArtSource *string
|
|
CoverArtSourcesVersion int32
|
|
}
|
|
|
|
// 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.
|
|
func (q *Queries) SetAlbumCoverWithVersion(ctx context.Context, arg SetAlbumCoverWithVersionParams) error {
|
|
_, err := q.db.Exec(ctx, setAlbumCoverWithVersion,
|
|
arg.ID,
|
|
arg.CoverArtPath,
|
|
arg.CoverArtSource,
|
|
arg.CoverArtSourcesVersion,
|
|
)
|
|
return err
|
|
}
|
|
|
|
const setAlbumMbidIfNull = `-- name: SetAlbumMbidIfNull :exec
|
|
UPDATE albums
|
|
SET mbid = $2,
|
|
updated_at = now()
|
|
WHERE id = $1 AND mbid IS NULL
|
|
`
|
|
|
|
type SetAlbumMbidIfNullParams struct {
|
|
ID pgtype.UUID
|
|
Mbid *string
|
|
}
|
|
|
|
// M7 #379: heal MBID on existing album rows during rescan. Idempotent —
|
|
// only updates when the existing mbid is NULL.
|
|
func (q *Queries) SetAlbumMbidIfNull(ctx context.Context, arg SetAlbumMbidIfNullParams) error {
|
|
_, err := q.db.Exec(ctx, setAlbumMbidIfNull, arg.ID, arg.Mbid)
|
|
return err
|
|
}
|
|
|
|
const upsertAlbum = `-- 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 id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at, cover_art_source, cover_art_sources_version
|
|
`
|
|
|
|
type UpsertAlbumParams struct {
|
|
Title string
|
|
SortTitle string
|
|
ArtistID pgtype.UUID
|
|
ReleaseDate pgtype.Date
|
|
Mbid *string
|
|
CoverArtPath *string
|
|
}
|
|
|
|
func (q *Queries) UpsertAlbum(ctx context.Context, arg UpsertAlbumParams) (Album, error) {
|
|
row := q.db.QueryRow(ctx, upsertAlbum,
|
|
arg.Title,
|
|
arg.SortTitle,
|
|
arg.ArtistID,
|
|
arg.ReleaseDate,
|
|
arg.Mbid,
|
|
arg.CoverArtPath,
|
|
)
|
|
var i Album
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.SortTitle,
|
|
&i.ArtistID,
|
|
&i.ReleaseDate,
|
|
&i.Mbid,
|
|
&i.CoverArtPath,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.CoverArtSource,
|
|
&i.CoverArtSourcesVersion,
|
|
)
|
|
return i, err
|
|
}
|