feat(playlists): track operations + cover-collage generator

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.
This commit is contained in:
2026-05-03 10:25:20 -04:00
parent 5c61c10b63
commit 79bab14b30
5 changed files with 634 additions and 7 deletions
+179 -7
View File
@@ -6,10 +6,10 @@
// - 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.
// 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 (
@@ -30,9 +30,10 @@ import (
// 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")
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.
@@ -291,6 +292,177 @@ func (s *Service) Delete(ctx context.Context, callerID, playlistID pgtype.UUID)
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,