// 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 } // 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, } // 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 }