Files
minstrel/internal/playlists/service.go
T
bvandeusen 5c61c10b63 feat(playlists): Service with CRUD methods (M7 #352 slice 1)
Create / Get / List / Update / Delete with the visibility model from
the spec: private by default, owner-only mutations, public read for
non-owners. Update uses sqlc's CASE-WHEN-flag pattern for PATCH-style
partial updates without writing N variants.

Delete cleans up the cached cover file from disk best-effort. Track
operations (Append/Remove/Reorder) and the collage generator land
in subsequent tasks.

Also adds playlists + playlist_tracks to dbtest.ResetDB's truncate
list so integration tests in this package start from a clean state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 10:17:08 -04:00

335 lines
10 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 (Append / Remove / Reorder) and the cover-art
// collage generator land in subsequent tasks; this file ships the
// CRUD-only slice on top of the migration 0014 + sqlc queries from
// task 1.
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/db/dbq"
)
// Typed errors. The API layer maps each to its wire status code; tests
// use errors.Is to assert the right path was taken.
var (
ErrNotFound = errors.New("playlists: playlist not found")
ErrForbidden = errors.New("playlists: forbidden")
ErrInvalidInput = errors.New("playlists: invalid input")
)
// 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
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)
}
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
}
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)
}
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
}
deleted, err := q.DeletePlaylist(ctx, playlistID)
if err != nil {
return fmt.Errorf("delete playlist: %w", 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
}
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,
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,
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
}