Files
minstrel/internal/db/dbq/playlists.sql.go
T
bvandeusen c3f3a17c6d
test-go / test (push) Successful in 59s
test-go / integration (push) Successful in 4m49s
feat(library): a missing file stays in the playlist, greyed and unplayable — #2527
Every browse, discover and mix query filters missing_since, so a track
whose file vanished disappears from the places Minstrel chooses music.
A playlist is different: the entry is there because the user put it
there, and silently dropping it rewrites their list behind their back.

So playlists keep the row and mark it instead. ListPlaylistTracks now
carries missing_since (still deliberately unfiltered), the service
layer surfaces it as PlaylistTrack.Unavailable, and the wire gains
"unavailable" on each entry.

A missing entry also loses its stream_url. Refusing to hand out a URL
that cannot serve is stronger than trusting every client to honour the
flag, and "stream_url": null is a shape the clients already model --
PlaylistWire.streamUrl is documented nullable for the track-removed
case -- so an older build degrades to "present but not playable" with
no change.

Nothing is deleted here and nothing should be: the row, its play
history, its likes and its taste contribution all survive a file going
missing, because the file may come back (and #2528 will adopt it if it
comes back renamed).

Also corrects two comments that had drifted into lying. delete.go still
claimed the file-gone case was NOT auto-reconciled and told admins to
delete rows by hand -- untrue since f6d1cf24, and that exact staleness
is what produced drift #572. It now says what DeleteTrackFile really is:
the destructive admin action, which CASCADEs play_events and likes, and
is emphatically not the missing-file path. watcher.go claimed the
safety-net scan "covers anything missed"; the walk only covers
additions, and it is reconcile that covers removals.
2026-08-16 11:43:05 -04:00

498 lines
14 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: playlists.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
SELECT
$1::uuid,
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
t.id,
t.title,
artists.name,
albums.title,
(t.duration_ms / 1000)::integer,
$2::text
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.id = $3::uuid
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at, pick_kind
`
type AppendPlaylistTrackParams struct {
PlaylistID pgtype.UUID
PickKind *string
TrackID pgtype.UUID
}
// Inserts at the next available position. Snapshot fields are copied
// from the tracks/albums/artists join at insert time. tracks.duration_ms
// is converted to seconds for the snapshot. pick_kind is NULL for manual
// playlists; the For You builder stamps 'taste'/'fresh' (#1249).
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.PickKind, arg.TrackID)
var i PlaylistTrack
err := row.Scan(
&i.PlaylistID,
&i.Position,
&i.TrackID,
&i.Title,
&i.ArtistName,
&i.AlbumTitle,
&i.DurationSec,
&i.AddedAt,
&i.PickKind,
)
return i, err
}
const createPlaylist = `-- name: CreatePlaylist :one
INSERT INTO playlists (user_id, name, description, is_public)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, name, description, is_public, cover_path, track_count, duration_sec, created_at, updated_at, kind, system_variant, seed_artist_id
`
type CreatePlaylistParams struct {
UserID pgtype.UUID
Name string
Description string
IsPublic bool
}
func (q *Queries) CreatePlaylist(ctx context.Context, arg CreatePlaylistParams) (Playlist, error) {
row := q.db.QueryRow(ctx, createPlaylist,
arg.UserID,
arg.Name,
arg.Description,
arg.IsPublic,
)
var i Playlist
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.IsPublic,
&i.CoverPath,
&i.TrackCount,
&i.DurationSec,
&i.CreatedAt,
&i.UpdatedAt,
&i.Kind,
&i.SystemVariant,
&i.SeedArtistID,
)
return i, err
}
const deletePlaylist = `-- name: DeletePlaylist :one
DELETE FROM playlists WHERE id = $1
RETURNING id, cover_path
`
type DeletePlaylistRow struct {
ID pgtype.UUID
CoverPath *string
}
// Returns cover_path so the caller can clean up the cached collage on disk.
func (q *Queries) DeletePlaylist(ctx context.Context, id pgtype.UUID) (DeletePlaylistRow, error) {
row := q.db.QueryRow(ctx, deletePlaylist, id)
var i DeletePlaylistRow
err := row.Scan(&i.ID, &i.CoverPath)
return i, err
}
const deletePlaylistTrack = `-- name: DeletePlaylistTrack :exec
DELETE FROM playlist_tracks
WHERE playlist_id = $1 AND position = $2
`
type DeletePlaylistTrackParams struct {
PlaylistID pgtype.UUID
Position int32
}
// Two-step: delete the row at `position`, then renumber subsequent rows
// to close the gap. The renumber is a single UPDATE; the service layer
// runs both in one transaction.
func (q *Queries) DeletePlaylistTrack(ctx context.Context, arg DeletePlaylistTrackParams) error {
_, err := q.db.Exec(ctx, deletePlaylistTrack, arg.PlaylistID, arg.Position)
return err
}
const getPlaylist = `-- name: GetPlaylist :one
SELECT p.id, p.user_id, p.name, p.description, p.is_public, p.cover_path, p.track_count, p.duration_sec, p.created_at, p.updated_at, p.kind, p.system_variant, p.seed_artist_id, u.username AS owner_username
FROM playlists p
JOIN users u ON u.id = p.user_id
WHERE p.id = $1
`
type GetPlaylistRow struct {
ID pgtype.UUID
UserID pgtype.UUID
Name string
Description string
IsPublic bool
CoverPath *string
TrackCount int32
DurationSec int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
Kind string
SystemVariant *string
SeedArtistID pgtype.UUID
OwnerUsername string
}
func (q *Queries) GetPlaylist(ctx context.Context, id pgtype.UUID) (GetPlaylistRow, error) {
row := q.db.QueryRow(ctx, getPlaylist, id)
var i GetPlaylistRow
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.IsPublic,
&i.CoverPath,
&i.TrackCount,
&i.DurationSec,
&i.CreatedAt,
&i.UpdatedAt,
&i.Kind,
&i.SystemVariant,
&i.SeedArtistID,
&i.OwnerUsername,
)
return i, err
}
const getPlaylistsByIDs = `-- name: GetPlaylistsByIDs :many
SELECT id, user_id, name, description, is_public, cover_path, track_count, duration_sec, created_at, updated_at, kind, system_variant, seed_artist_id FROM playlists WHERE id = ANY($1::uuid[])
`
// Batched lookup used by /api/library/sync to hydrate upsert payloads
// (#357). Mirror of GetArtistsByIDs.
func (q *Queries) GetPlaylistsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Playlist, error) {
rows, err := q.db.Query(ctx, getPlaylistsByIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Playlist
for rows.Next() {
var i Playlist
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.IsPublic,
&i.CoverPath,
&i.TrackCount,
&i.DurationSec,
&i.CreatedAt,
&i.UpdatedAt,
&i.Kind,
&i.SystemVariant,
&i.SeedArtistID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAllPlaylistTracksForCollage = `-- name: ListAllPlaylistTracksForCollage :many
SELECT pt.position,
albums.cover_art_path AS album_cover_path
FROM playlist_tracks pt
LEFT JOIN tracks t ON t.id = pt.track_id
LEFT JOIN albums ON albums.id = t.album_id
WHERE pt.playlist_id = $1
ORDER BY pt.position
LIMIT $2
`
type ListAllPlaylistTracksForCollageParams struct {
PlaylistID pgtype.UUID
Limit int32
}
type ListAllPlaylistTracksForCollageRow struct {
Position int32
AlbumCoverPath *string
}
// First N tracks for the collage. Uses LEFT JOIN on albums for the
// cover_path; rows with NULL cover_path get the glyph fallback.
func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListAllPlaylistTracksForCollageParams) ([]ListAllPlaylistTracksForCollageRow, error) {
rows, err := q.db.Query(ctx, listAllPlaylistTracksForCollage, arg.PlaylistID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListAllPlaylistTracksForCollageRow
for rows.Next() {
var i ListAllPlaylistTracksForCollageRow
if err := rows.Scan(&i.Position, &i.AlbumCoverPath); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPlaylistTracks = `-- name: ListPlaylistTracks :many
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, pt.pick_kind,
t.id AS live_track_id,
t.missing_since AS missing_since,
albums.id AS album_id,
artists.id AS artist_id
FROM playlist_tracks pt
LEFT JOIN tracks t ON t.id = pt.track_id
LEFT JOIN albums ON albums.id = t.album_id
LEFT JOIN artists ON artists.id = t.artist_id
WHERE pt.playlist_id = $1
ORDER BY pt.position
`
type ListPlaylistTracksRow struct {
PlaylistID pgtype.UUID
Position int32
TrackID pgtype.UUID
Title string
ArtistName string
AlbumTitle string
DurationSec int32
AddedAt pgtype.Timestamptz
PickKind *string
LiveTrackID pgtype.UUID
MissingSince pgtype.Timestamptz
AlbumID pgtype.UUID
ArtistID pgtype.UUID
}
// Joined to tracks for the live track id (the service layer derives the
// stream URL from it); LEFT JOIN preserves the row when track_id is NULL
// (track was removed from the library). The denormalized snapshot fields
// on playlist_tracks remain authoritative for title/artist/album text.
//
// Deliberately NOT filtered on missing_since (#2527), unlike every browse /
// discover / mix query. A playlist entry is something the user put here on
// purpose, so a missing file stays in the list and renders as a dead row
// rather than silently vanishing; missing_since rides along so the service
// layer can mark it unplayable.
func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID) ([]ListPlaylistTracksRow, error) {
rows, err := q.db.Query(ctx, listPlaylistTracks, playlistID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPlaylistTracksRow
for rows.Next() {
var i ListPlaylistTracksRow
if err := rows.Scan(
&i.PlaylistID,
&i.Position,
&i.TrackID,
&i.Title,
&i.ArtistName,
&i.AlbumTitle,
&i.DurationSec,
&i.AddedAt,
&i.PickKind,
&i.LiveTrackID,
&i.MissingSince,
&i.AlbumID,
&i.ArtistID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPlaylistsForUser = `-- name: ListPlaylistsForUser :many
SELECT p.id, p.user_id, p.name, p.description, p.is_public, p.cover_path, p.track_count, p.duration_sec, p.created_at, p.updated_at, p.kind, p.system_variant, p.seed_artist_id, u.username AS owner_username
FROM playlists p
JOIN users u ON u.id = p.user_id
WHERE p.user_id = $1 OR p.is_public = true
ORDER BY p.updated_at DESC
`
type ListPlaylistsForUserRow struct {
ID pgtype.UUID
UserID pgtype.UUID
Name string
Description string
IsPublic bool
CoverPath *string
TrackCount int32
DurationSec int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
Kind string
SystemVariant *string
SeedArtistID pgtype.UUID
OwnerUsername string
}
// Owner's playlists (any visibility) + other users' public playlists.
// Ordered by updated_at desc so newly-edited ones float to the top.
func (q *Queries) ListPlaylistsForUser(ctx context.Context, userID pgtype.UUID) ([]ListPlaylistsForUserRow, error) {
rows, err := q.db.Query(ctx, listPlaylistsForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPlaylistsForUserRow
for rows.Next() {
var i ListPlaylistsForUserRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.IsPublic,
&i.CoverPath,
&i.TrackCount,
&i.DurationSec,
&i.CreatedAt,
&i.UpdatedAt,
&i.Kind,
&i.SystemVariant,
&i.SeedArtistID,
&i.OwnerUsername,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const renumberPlaylistTracksAfter = `-- name: RenumberPlaylistTracksAfter :exec
UPDATE playlist_tracks
SET position = position - 1
WHERE playlist_id = $1 AND position > $2
`
type RenumberPlaylistTracksAfterParams struct {
PlaylistID pgtype.UUID
Position int32
}
// Used after DeletePlaylistTrack to close the gap.
func (q *Queries) RenumberPlaylistTracksAfter(ctx context.Context, arg RenumberPlaylistTracksAfterParams) error {
_, err := q.db.Exec(ctx, renumberPlaylistTracksAfter, arg.PlaylistID, arg.Position)
return err
}
const setPlaylistCover = `-- name: SetPlaylistCover :exec
UPDATE playlists SET cover_path = $2, updated_at = now() WHERE id = $1
`
type SetPlaylistCoverParams struct {
ID pgtype.UUID
CoverPath *string
}
func (q *Queries) SetPlaylistCover(ctx context.Context, arg SetPlaylistCoverParams) error {
_, err := q.db.Exec(ctx, setPlaylistCover, arg.ID, arg.CoverPath)
return err
}
const updatePlaylist = `-- name: UpdatePlaylist :one
UPDATE playlists
SET
name = CASE WHEN $1::boolean THEN $2::text ELSE name END,
description = CASE WHEN $3::boolean THEN $4::text ELSE description END,
is_public = CASE WHEN $5::boolean THEN $6::boolean ELSE is_public END,
updated_at = now()
WHERE id = $7
RETURNING id, user_id, name, description, is_public, cover_path, track_count, duration_sec, created_at, updated_at, kind, system_variant, seed_artist_id
`
type UpdatePlaylistParams struct {
UpdateName bool
Name string
UpdateDescription bool
Description string
UpdateIsPublic bool
IsPublic bool
ID pgtype.UUID
}
// Updates only the fields whose corresponding `updateX` flag is true.
// The flags let the service layer keep PATCH semantics (only-touch-what-the-caller-sent)
// without writing N variants.
func (q *Queries) UpdatePlaylist(ctx context.Context, arg UpdatePlaylistParams) (Playlist, error) {
row := q.db.QueryRow(ctx, updatePlaylist,
arg.UpdateName,
arg.Name,
arg.UpdateDescription,
arg.Description,
arg.UpdateIsPublic,
arg.IsPublic,
arg.ID,
)
var i Playlist
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.Description,
&i.IsPublic,
&i.CoverPath,
&i.TrackCount,
&i.DurationSec,
&i.CreatedAt,
&i.UpdatedAt,
&i.Kind,
&i.SystemVariant,
&i.SeedArtistID,
)
return i, err
}
const updatePlaylistRollups = `-- name: UpdatePlaylistRollups :exec
UPDATE playlists
SET
track_count = (SELECT COUNT(*) FROM playlist_tracks pt WHERE pt.playlist_id = $1),
duration_sec = (SELECT COALESCE(SUM(pt.duration_sec), 0) FROM playlist_tracks pt WHERE pt.playlist_id = $1),
updated_at = now()
WHERE id = $1
`
// Set track_count + duration_sec from a fresh aggregate. Called after
// every mutation that touches playlist_tracks. Cheap; the table is small.
func (q *Queries) UpdatePlaylistRollups(ctx context.Context, playlistID pgtype.UUID) error {
_, err := q.db.Exec(ctx, updatePlaylistRollups, playlistID)
return err
}