79bab14b30
AppendTracks / RemoveTrack / Reorder run in single transactions and update the denormalized rollups (track_count, duration_sec). Reorder uses a +10000 offset to bump every row out of the position range before writing the new positions, sidestepping PK conflicts during rewrite. GenerateCollage composes a 600x600 JPEG from the first 4 album covers, with a slate-tinted fallback for missing cells. Synchronous, called inline after every mutating operation. SVG-rasterized fallback is a follow-up — slice 1 ships with a solid placeholder.
507 lines
16 KiB
Go
507 lines
16 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/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")
|
|
ErrTrackNotFound = errors.New("playlists: track not found")
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
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)
|
|
|
|
if err := tq.DeletePlaylistTrack(ctx, dbq.DeletePlaylistTrackParams{
|
|
PlaylistID: playlistID,
|
|
Position: position,
|
|
}); err != nil {
|
|
return fmt.Errorf("delete track row: %w", err)
|
|
}
|
|
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 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,
|
|
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
|
|
}
|