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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user