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:
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
)
|
||||
|
||||
@@ -318,3 +320,159 @@ func TestDelete_MissingCoverFileTolerated(t *testing.T) {
|
||||
t.Fatalf("Delete with missing cover file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendTracks_Snapshot(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
track := seedTrack(t, pool, "Roygbiv", "Boards of Canada")
|
||||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||||
|
||||
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
|
||||
if err := svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{track.ID}); err != nil {
|
||||
t.Fatalf("AppendTracks: %v", err)
|
||||
}
|
||||
|
||||
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
|
||||
if len(got.Tracks) != 1 {
|
||||
t.Fatalf("Tracks length = %d, want 1", len(got.Tracks))
|
||||
}
|
||||
if got.Tracks[0].Title != "Roygbiv" {
|
||||
t.Errorf("Title snapshot = %q, want Roygbiv", got.Tracks[0].Title)
|
||||
}
|
||||
if got.Tracks[0].ArtistName != "Boards of Canada" {
|
||||
t.Errorf("ArtistName snapshot = %q, want Boards of Canada", got.Tracks[0].ArtistName)
|
||||
}
|
||||
if got.TrackCount != 1 {
|
||||
t.Errorf("rollup TrackCount = %d, want 1", got.TrackCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendTracks_OwnerOnly(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
alice := seedUser(t, pool, "alice")
|
||||
bob := seedUser(t, pool, "bob")
|
||||
track := seedTrack(t, pool, "T", "A")
|
||||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||||
|
||||
pl, _ := svc.Create(context.Background(), alice.ID, "Mine", "", true)
|
||||
err := svc.AppendTracks(context.Background(), bob.ID, pl.ID, []pgtype.UUID{track.ID})
|
||||
if !errors.Is(err, playlists.ErrForbidden) {
|
||||
t.Errorf("err = %v, want ErrForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendTracks_NonExistentTrack(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||||
|
||||
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
|
||||
err := svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{randomUUID()})
|
||||
if !errors.Is(err, playlists.ErrTrackNotFound) {
|
||||
t.Errorf("err = %v, want ErrTrackNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTrack_RenumbersPositions(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
t1 := seedTrack(t, pool, "Track 1", "Artist")
|
||||
t2 := seedTrack(t, pool, "Track 2", "Artist")
|
||||
t3 := seedTrack(t, pool, "Track 3", "Artist")
|
||||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||||
|
||||
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
|
||||
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{t1.ID, t2.ID, t3.ID})
|
||||
|
||||
// Remove the middle one (position 1).
|
||||
if err := svc.RemoveTrack(context.Background(), user.ID, pl.ID, 1); err != nil {
|
||||
t.Fatalf("RemoveTrack: %v", err)
|
||||
}
|
||||
|
||||
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
|
||||
if len(got.Tracks) != 2 {
|
||||
t.Fatalf("Tracks length = %d, want 2", len(got.Tracks))
|
||||
}
|
||||
if got.Tracks[0].Title != "Track 1" {
|
||||
t.Errorf("position 0 = %q, want Track 1", got.Tracks[0].Title)
|
||||
}
|
||||
if got.Tracks[1].Title != "Track 3" {
|
||||
t.Errorf("position 1 = %q, want Track 3 (renumbered from 2)", got.Tracks[1].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorder_Permutation(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
t1 := seedTrack(t, pool, "A", "Artist")
|
||||
t2 := seedTrack(t, pool, "B", "Artist")
|
||||
t3 := seedTrack(t, pool, "C", "Artist")
|
||||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||||
|
||||
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
|
||||
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{t1.ID, t2.ID, t3.ID})
|
||||
|
||||
// Reverse order: send [2, 1, 0].
|
||||
if err := svc.Reorder(context.Background(), user.ID, pl.ID, []int32{2, 1, 0}); err != nil {
|
||||
t.Fatalf("Reorder: %v", err)
|
||||
}
|
||||
|
||||
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
|
||||
wantOrder := []string{"C", "B", "A"}
|
||||
for i, pt := range got.Tracks {
|
||||
if pt.Title != wantOrder[i] {
|
||||
t.Errorf("position %d = %q, want %q", i, pt.Title, wantOrder[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorder_RejectsNonPermutation(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
tk := seedTrack(t, pool, "T", "A")
|
||||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||||
|
||||
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
|
||||
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID})
|
||||
|
||||
// 2 positions for a 1-track playlist — invalid.
|
||||
err := svc.Reorder(context.Background(), user.ID, pl.ID, []int32{0, 1})
|
||||
if !errors.Is(err, playlists.ErrInvalidInput) {
|
||||
t.Errorf("err = %v, want ErrInvalidInput (length mismatch)", err)
|
||||
}
|
||||
|
||||
pl2, _ := svc.Create(context.Background(), user.ID, "Two", "", false)
|
||||
t2 := seedTrack(t, pool, "T2", "A")
|
||||
_ = svc.AppendTracks(context.Background(), user.ID, pl2.ID, []pgtype.UUID{tk.ID, t2.ID})
|
||||
err = svc.Reorder(context.Background(), user.ID, pl2.ID, []int32{0, 0})
|
||||
if !errors.Is(err, playlists.ErrInvalidInput) {
|
||||
t.Errorf("err = %v, want ErrInvalidInput (duplicate)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotPersistsAfterUpstreamTrackDelete(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
tk := seedTrack(t, pool, "Doomed track", "Artist")
|
||||
svc := playlists.NewService(pool, nil, t.TempDir())
|
||||
|
||||
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
|
||||
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID})
|
||||
|
||||
// Delete the track row directly (simulating Lidarr re-import / file removal).
|
||||
if _, err := pool.Exec(context.Background(), `DELETE FROM tracks WHERE id = $1`, tk.ID); err != nil {
|
||||
t.Fatalf("delete track: %v", err)
|
||||
}
|
||||
|
||||
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
|
||||
if len(got.Tracks) != 1 {
|
||||
t.Fatalf("Tracks length = %d, want 1 (soft-mark not silent-cascade)", len(got.Tracks))
|
||||
}
|
||||
row := got.Tracks[0]
|
||||
if row.TrackID != nil {
|
||||
t.Error("TrackID should be nil after upstream delete")
|
||||
}
|
||||
if row.Title != "Doomed track" {
|
||||
t.Errorf("Snapshot Title = %q, want Doomed track", row.Title)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user