Files
minstrel/internal/playlists/service.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

606 lines
20 KiB
Go

// Package playlists implements the user-facing playlist CRUD service.
//
// Visibility model (from the M7 #352 design spec):
// - Playlists are private by default. Only the owner sees private rows
// in List, Get, and is allowed to mutate them via Update / Delete.
// - is_public = true makes a playlist readable by any authenticated
// user; mutations are still owner-only.
//
// Track-list mutations (AppendTracks / RemoveTrack / Reorder) bump
// the denormalized rollups (track_count, duration_sec) on the playlist
// row and trigger cover-art regeneration via GenerateCollage. The
// collage generator itself lives alongside in collage.go.
package playlists
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// Typed errors. The API layer maps each to its wire status code; tests
// use errors.Is to assert the right path was taken.
//
// ErrNotFound aliases apierror.ErrNotFound so handlers can errors.Is
// against the shared sentinel; existing playlists.ErrNotFound callsites
// still resolve to the same pointer.
var (
ErrNotFound = apierror.ErrNotFound
ErrForbidden = errors.New("playlists: forbidden")
ErrInvalidInput = errors.New("playlists: invalid input")
ErrTrackNotFound = errors.New("playlists: track not found")
ErrSystemPlaylistReadonly = errors.New("playlists: system playlist is read-only")
)
// Service wires the sqlc queries + on-disk cover-art directory together.
// dataDir is the root for cached cover-art collages (cover_path on the
// playlist row is stored relative to this directory so the install can
// be relocated without rewriting rows).
type Service struct {
pool *pgxpool.Pool
logger *slog.Logger
dataDir string
}
// NewService constructs a Service. A nil logger gets slog.Default().
func NewService(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) *Service {
if logger == nil {
logger = slog.Default()
}
return &Service{pool: pool, logger: logger, dataDir: dataDir}
}
// PlaylistRow is the wire shape returned by Create / Update / List /
// the embedded portion of Get. owner_username is denormalized in via
// the JOIN on users so callers don't have to resolve it themselves.
type PlaylistRow struct {
ID pgtype.UUID
UserID pgtype.UUID
OwnerUsername string
Name string
Description string
IsPublic bool
Kind string // 'user' | 'system'
SystemVariant *string // 'for_you' | 'songs_like_artist' | nil
SeedArtistID pgtype.UUID // Valid=true only for 'songs_like_artist'
CoverPath *string
TrackCount int32
DurationSec int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
// PlaylistDetail extends PlaylistRow with the ordered track list.
// Returned by Get only; the List endpoint stays cheap and returns
// header-only PlaylistRows.
type PlaylistDetail struct {
PlaylistRow
Tracks []PlaylistTrack
}
// PlaylistTrack is one row of a playlist's ordered track list.
//
// Title / ArtistName / AlbumTitle / DurationSec are the snapshot the
// playlist row was created with — they remain authoritative even after
// the upstream tracks/albums/artists rows change or disappear.
//
// TrackID is nil when the upstream tracks row has been deleted from
// the library (ON DELETE SET NULL). AlbumID / ArtistID come from the
// LEFT JOIN through the live track and follow the same nil-on-removed
// semantics — once the track row is gone, neither the album nor the
// artist can be resolved either, so they collapse to nil together.
type PlaylistTrack struct {
Position int32
TrackID *pgtype.UUID
AlbumID *pgtype.UUID
ArtistID *pgtype.UUID
Title string
ArtistName string
AlbumTitle string
DurationSec int32
AddedAt pgtype.Timestamptz
// Unavailable reports that the track still exists in the library but
// its file is currently missing from disk (tracks.missing_since is
// set, #2527). The entry keeps its place in the playlist — the user
// put it there — but nothing should try to play it.
Unavailable bool
}
// Create makes a new playlist owned by userID.
//
// The post-create GetPlaylist call exists to pick up owner_username
// from the users JOIN — CreatePlaylist returns only playlist columns,
// not the joined username, so we'd otherwise have to resolve it twice
// in the API layer.
func (s *Service) Create(ctx context.Context, userID pgtype.UUID, name, description string, isPublic bool) (*PlaylistRow, error) {
if name == "" {
return nil, fmt.Errorf("%w: name required", ErrInvalidInput)
}
q := dbq.New(s.pool)
row, err := q.CreatePlaylist(ctx, dbq.CreatePlaylistParams{
UserID: userID,
Name: name,
Description: description,
IsPublic: isPublic,
})
if err != nil {
return nil, fmt.Errorf("create playlist: %w", err)
}
if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityPlaylist,
syncpkg.FormatUUID(row.ID), syncpkg.OpUpsert); err != nil {
s.logger.Warn("playlists: LogChange create failed", "playlist_id", row.ID, "err", err)
}
full, err := q.GetPlaylist(ctx, row.ID)
if err != nil {
return nil, fmt.Errorf("get-after-create: %w", err)
}
return playlistFromGetRow(full), nil
}
// Get returns the playlist identified by playlistID along with its
// ordered track list. Visibility: owner can always see; non-owners
// only see public playlists. Returns ErrNotFound for missing rows
// and ErrForbidden when a non-owner asks for a private one.
func (s *Service) Get(ctx context.Context, callerID, playlistID pgtype.UUID) (*PlaylistDetail, error) {
q := dbq.New(s.pool)
row, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get playlist: %w", err)
}
if !row.IsPublic && !pgtypeUUIDEqual(row.UserID, callerID) {
return nil, ErrForbidden
}
trackRows, err := q.ListPlaylistTracks(ctx, playlistID)
if err != nil {
return nil, fmt.Errorf("list tracks: %w", err)
}
tracks := make([]PlaylistTrack, 0, len(trackRows))
for _, t := range trackRows {
pt := PlaylistTrack{
Position: t.Position,
Title: t.Title,
ArtistName: t.ArtistName,
AlbumTitle: t.AlbumTitle,
DurationSec: t.DurationSec,
AddedAt: t.AddedAt,
Unavailable: t.MissingSince.Valid,
}
// pt.track_id (snapshot FK, ON DELETE SET NULL) and the joined
// live_track_id should agree on validity in normal operation —
// prefer the snapshot's track_id because it survives even when
// the live row's other join columns happen to be NULL for
// unrelated reasons.
if t.TrackID.Valid {
id := t.TrackID
pt.TrackID = &id
}
if t.AlbumID.Valid {
id := t.AlbumID
pt.AlbumID = &id
}
if t.ArtistID.Valid {
id := t.ArtistID
pt.ArtistID = &id
}
tracks = append(tracks, pt)
}
return &PlaylistDetail{
PlaylistRow: *playlistFromGetRow(row),
Tracks: tracks,
}, nil
}
// List returns all playlists visible to callerID: every playlist owned
// by the caller (any visibility) plus every other user's public
// playlists, ordered newest-first by updated_at.
func (s *Service) List(ctx context.Context, callerID pgtype.UUID) ([]PlaylistRow, error) {
q := dbq.New(s.pool)
rows, err := q.ListPlaylistsForUser(ctx, callerID)
if err != nil {
return nil, fmt.Errorf("list playlists: %w", err)
}
out := make([]PlaylistRow, 0, len(rows))
for _, r := range rows {
out = append(out, *playlistFromListRow(r))
}
return out, nil
}
// UpdateInput captures optional fields. Each pointer being nil means
// "don't touch" — non-nil triggers a write of that column. The sqlc
// UpdatePlaylist query uses CASE-WHEN flags so we can keep PATCH
// semantics with one query rather than N variants.
type UpdateInput struct {
Name *string
Description *string
IsPublic *bool
}
// Update applies a partial update to playlistID. Only the owner may
// mutate — non-owners (including admins, currently) get ErrForbidden.
// Empty Name (when explicitly set) is rejected with ErrInvalidInput
// rather than silently no-op'd, since the user clearly intended to
// change something.
func (s *Service) Update(ctx context.Context, callerID, playlistID pgtype.UUID, in UpdateInput) (*PlaylistRow, error) {
q := dbq.New(s.pool)
existing, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get-before-update: %w", err)
}
if !pgtypeUUIDEqual(existing.UserID, callerID) {
return nil, ErrForbidden
}
if existing.Kind == "system" {
return nil, ErrSystemPlaylistReadonly
}
params := dbq.UpdatePlaylistParams{
ID: playlistID,
UpdateName: in.Name != nil,
UpdateDescription: in.Description != nil,
UpdateIsPublic: in.IsPublic != nil,
}
if in.Name != nil {
if *in.Name == "" {
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
}
params.Name = *in.Name
}
if in.Description != nil {
params.Description = *in.Description
}
if in.IsPublic != nil {
params.IsPublic = *in.IsPublic
}
updated, err := q.UpdatePlaylist(ctx, params)
if err != nil {
return nil, fmt.Errorf("update playlist: %w", err)
}
if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityPlaylist,
syncpkg.FormatUUID(updated.ID), syncpkg.OpUpsert); err != nil {
s.logger.Warn("playlists: LogChange update failed", "playlist_id", updated.ID, "err", err)
}
full, err := q.GetPlaylist(ctx, updated.ID)
if err != nil {
return nil, fmt.Errorf("get-after-update: %w", err)
}
return playlistFromGetRow(full), nil
}
// Delete removes playlistID. Owner-only, like Update. After the row is
// gone, a best-effort attempt is made to remove the cached cover-art
// collage from disk; failures other than ENOENT are logged but do not
// fail the call (the row is already gone, returning an error would
// confuse callers about whether the delete took effect).
func (s *Service) Delete(ctx context.Context, callerID, playlistID pgtype.UUID) error {
q := dbq.New(s.pool)
existing, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("get-before-delete: %w", err)
}
if !pgtypeUUIDEqual(existing.UserID, callerID) {
return ErrForbidden
}
if existing.Kind == "system" {
return ErrSystemPlaylistReadonly
}
deleted, err := q.DeletePlaylist(ctx, playlistID)
if err != nil {
return fmt.Errorf("delete playlist: %w", err)
}
if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityPlaylist,
syncpkg.FormatUUID(deleted.ID), syncpkg.OpDelete); err != nil {
s.logger.Warn("playlists: LogChange delete failed", "playlist_id", deleted.ID, "err", err)
}
if deleted.CoverPath != nil && *deleted.CoverPath != "" {
full := filepath.Join(s.dataDir, *deleted.CoverPath)
if rerr := os.Remove(full); rerr != nil && !errors.Is(rerr, os.ErrNotExist) {
s.logger.Warn("playlist delete: cover file remove failed",
"path", full, "playlist_id", playlistID, "err", rerr)
}
}
return nil
}
// AssertEditable returns nil if the playlist exists, the caller owns it,
// AND the playlist is user-created (kind='user'). Returns:
// - ErrNotFound — playlist doesn't exist
// - ErrForbidden — caller is not the owner
// - ErrSystemPlaylistReadonly — playlist exists but kind='system'
//
// Edit handlers call this first; on error, the API layer maps the typed
// error to the appropriate status code.
func (s *Service) AssertEditable(ctx context.Context, callerID, playlistID pgtype.UUID) error {
q := dbq.New(s.pool)
p, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("playlists: load for editability check: %w", err)
}
if !pgtypeUUIDEqual(p.UserID, callerID) {
return ErrForbidden
}
if p.Kind == "system" {
return ErrSystemPlaylistReadonly
}
return nil
}
// AppendTracks adds the given track ids at the end of the playlist,
// preserving order. Snapshot fields (title, artist, album, duration_sec)
// are populated from the tracks/albums/artists join at insert time.
// Triggers cover regeneration after.
func (s *Service) AppendTracks(ctx context.Context, callerID, playlistID pgtype.UUID, trackIDs []pgtype.UUID) error {
if len(trackIDs) == 0 {
return fmt.Errorf("%w: at least one track id required", ErrInvalidInput)
}
q := dbq.New(s.pool)
pl, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("get playlist: %w", err)
}
if !pgtypeUUIDEqual(pl.UserID, callerID) {
return ErrForbidden
}
if pl.Kind == "system" {
return ErrSystemPlaylistReadonly
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
tq := dbq.New(tx)
for _, tid := range trackIDs {
_, ierr := tq.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{
PlaylistID: playlistID,
TrackID: tid,
})
if ierr != nil {
// AppendPlaylistTrack uses INSERT ... SELECT FROM tracks WHERE id = X.
// If X doesn't exist, no row inserts and the :one query errors with
// pgx.ErrNoRows.
if errors.Is(ierr, pgx.ErrNoRows) {
return ErrTrackNotFound
}
return fmt.Errorf("append track: %w", ierr)
}
// Tx-bound: change row commits with the playlist_track row.
if lerr := syncpkg.LogChange(ctx, tx, syncpkg.EntityPlaylistTrack,
syncpkg.EncodePlaylistTrackID(syncpkg.FormatUUID(playlistID), syncpkg.FormatUUID(tid)),
syncpkg.OpUpsert); lerr != nil {
return fmt.Errorf("log change: %w", lerr)
}
}
if err := tq.UpdatePlaylistRollups(ctx, playlistID); err != nil {
return fmt.Errorf("update rollups: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
// Regenerate cover (synchronous). Failure is logged but not returned —
// the playlist mutation already committed.
if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil {
s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr)
}
return nil
}
// RemoveTrack deletes the row at `position` and renumbers subsequent
// rows to close the gap. Triggers cover regeneration after.
func (s *Service) RemoveTrack(ctx context.Context, callerID, playlistID pgtype.UUID, position int32) error {
q := dbq.New(s.pool)
pl, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("get playlist: %w", err)
}
if !pgtypeUUIDEqual(pl.UserID, callerID) {
return ErrForbidden
}
if pl.Kind == "system" {
return ErrSystemPlaylistReadonly
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
tq := dbq.New(tx)
// Capture the track_id before we delete the row — we need it for the
// LogChange composite key after the delete commits.
var deletedTrackID pgtype.UUID
if err := tx.QueryRow(ctx,
`SELECT track_id FROM playlist_tracks WHERE playlist_id = $1 AND position = $2`,
playlistID, position,
).Scan(&deletedTrackID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("lookup track at position: %w", err)
}
if err := tq.DeletePlaylistTrack(ctx, dbq.DeletePlaylistTrackParams{
PlaylistID: playlistID,
Position: position,
}); err != nil {
return fmt.Errorf("delete track row: %w", err)
}
if lerr := syncpkg.LogChange(ctx, tx, syncpkg.EntityPlaylistTrack,
syncpkg.EncodePlaylistTrackID(syncpkg.FormatUUID(playlistID), syncpkg.FormatUUID(deletedTrackID)),
syncpkg.OpDelete); lerr != nil {
return fmt.Errorf("log change: %w", lerr)
}
if err := tq.RenumberPlaylistTracksAfter(ctx, dbq.RenumberPlaylistTracksAfterParams{
PlaylistID: playlistID,
Position: position,
}); err != nil {
return fmt.Errorf("renumber: %w", err)
}
if err := tq.UpdatePlaylistRollups(ctx, playlistID); err != nil {
return fmt.Errorf("update rollups: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil {
s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr)
}
return nil
}
// Reorder atomically rewrites all positions to the supplied permutation.
// orderedPositions is the new positional order: if the playlist has 4
// tracks at positions [0, 1, 2, 3] and the caller wants the order
// (old[3], old[0], old[1], old[2]), they send [3, 0, 1, 2].
func (s *Service) Reorder(ctx context.Context, callerID, playlistID pgtype.UUID, orderedPositions []int32) error {
q := dbq.New(s.pool)
pl, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("get playlist: %w", err)
}
if !pgtypeUUIDEqual(pl.UserID, callerID) {
return ErrForbidden
}
if pl.Kind == "system" {
return ErrSystemPlaylistReadonly
}
if int32(len(orderedPositions)) != pl.TrackCount {
return fmt.Errorf("%w: expected %d positions, got %d", ErrInvalidInput, pl.TrackCount, len(orderedPositions))
}
seen := make(map[int32]struct{}, len(orderedPositions))
for _, p := range orderedPositions {
if p < 0 || p >= pl.TrackCount {
return fmt.Errorf("%w: position %d out of range", ErrInvalidInput, p)
}
if _, dup := seen[p]; dup {
return fmt.Errorf("%w: position %d duplicated", ErrInvalidInput, p)
}
seen[p] = struct{}{}
}
// Rewrite strategy: bump every row's position by +10000 first (out of
// range of valid positions and the unique PK) then write the new
// positions one by one. Single transaction.
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx,
`UPDATE playlist_tracks SET position = position + 10000 WHERE playlist_id = $1`,
playlistID); err != nil {
return fmt.Errorf("offset positions: %w", err)
}
for newPos, oldPos := range orderedPositions {
if _, err := tx.Exec(ctx,
`UPDATE playlist_tracks SET position = $1 WHERE playlist_id = $2 AND position = $3`,
int32(newPos), playlistID, oldPos+10000); err != nil {
return fmt.Errorf("write new position: %w", err)
}
}
if err := dbq.New(tx).UpdatePlaylistRollups(ctx, playlistID); err != nil {
return fmt.Errorf("update rollups: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
// Always regenerate the cover. Reasoning about which moves matter is
// more error-prone than just doing the work; the collage is cheap.
if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil {
s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr)
}
return nil
}
func playlistFromGetRow(r dbq.GetPlaylistRow) *PlaylistRow {
return &PlaylistRow{
ID: r.ID,
UserID: r.UserID,
OwnerUsername: r.OwnerUsername,
Name: r.Name,
Description: r.Description,
IsPublic: r.IsPublic,
Kind: r.Kind,
SystemVariant: r.SystemVariant,
SeedArtistID: r.SeedArtistID,
CoverPath: r.CoverPath,
TrackCount: r.TrackCount,
DurationSec: r.DurationSec,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
func playlistFromListRow(r dbq.ListPlaylistsForUserRow) *PlaylistRow {
return &PlaylistRow{
ID: r.ID,
UserID: r.UserID,
OwnerUsername: r.OwnerUsername,
Name: r.Name,
Description: r.Description,
IsPublic: r.IsPublic,
Kind: r.Kind,
SystemVariant: r.SystemVariant,
SeedArtistID: r.SeedArtistID,
CoverPath: r.CoverPath,
TrackCount: r.TrackCount,
DurationSec: r.DurationSec,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
// pgtypeUUIDEqual treats two zero/invalid UUIDs as not-equal. The
// service uses this for owner-vs-caller checks where an invalid UUID
// can never legitimately match anything.
func pgtypeUUIDEqual(a, b pgtype.UUID) bool {
if !a.Valid || !b.Valid {
return false
}
return a.Bytes == b.Bytes
}