From 79bab14b3074e7b24b7350a36844478943f25611 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 10:25:20 -0400 Subject: [PATCH] feat(playlists): track operations + cover-collage generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/playlists/collage.go | 179 +++++++++++++++++ internal/playlists/collage_test.go | 75 +++++++ internal/playlists/service.go | 186 +++++++++++++++++- internal/playlists/service_test.go | 158 +++++++++++++++ .../playlists/service_test_helpers_test.go | 43 ++++ 5 files changed, 634 insertions(+), 7 deletions(-) create mode 100644 internal/playlists/collage.go create mode 100644 internal/playlists/collage_test.go diff --git a/internal/playlists/collage.go b/internal/playlists/collage.go new file mode 100644 index 00000000..c99f5863 --- /dev/null +++ b/internal/playlists/collage.go @@ -0,0 +1,179 @@ +package playlists + +import ( + "context" + "fmt" + "image" + "image/color" + "image/draw" + "image/jpeg" + "os" + "path/filepath" + "strings" + + _ "image/png" // for decoding existing PNG covers (jpeg already linked above) + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +const ( + collageCellSize = 300 // px per cell, 2x2 → 600x600 output + collageOutputDim = collageCellSize * 2 + collageQuality = 85 +) + +// GenerateCollage composes a 600x600 JPEG from the first 4 contributing +// tracks' album covers. Missing covers (no upstream album, empty +// cover_art_path, file unreadable) get the album-fallback glyph in their +// cell. Writes to /playlist_covers/.jpg and +// updates playlists.cover_path. Returns the relative path written, or +// "" when the playlist is empty (cover_path also cleared). +func GenerateCollage(ctx context.Context, pool *pgxpool.Pool, playlistID pgtype.UUID, dataDir string) (string, error) { + q := dbq.New(pool) + rows, err := q.ListAllPlaylistTracksForCollage(ctx, dbq.ListAllPlaylistTracksForCollageParams{ + PlaylistID: playlistID, + Limit: 4, + }) + if err != nil { + return "", fmt.Errorf("list collage tracks: %w", err) + } + + if len(rows) == 0 { + // Empty playlist: clear cover_path and remove any stale file. + if err := q.SetPlaylistCover(ctx, dbq.SetPlaylistCoverParams{ + ID: playlistID, + CoverPath: nil, + }); err != nil { + return "", fmt.Errorf("clear cover_path: %w", err) + } + _ = os.Remove(filepath.Join(dataDir, "playlist_covers", uuidToFilename(playlistID)+".jpg")) + return "", nil + } + + out := image.NewRGBA(image.Rect(0, 0, collageOutputDim, collageOutputDim)) + // Fill with FabledSword `iron` (#1E2228) so any drawing gaps look intentional. + draw.Draw(out, out.Bounds(), &image.Uniform{C: color.RGBA{0x1E, 0x22, 0x28, 0xFF}}, image.Point{}, draw.Src) + + for cell := 0; cell < 4; cell++ { + var coverPath string + if cell < len(rows) && rows[cell].AlbumCoverPath != nil && *rows[cell].AlbumCoverPath != "" { + coverPath = *rows[cell].AlbumCoverPath + } + img := loadCellImage(coverPath, dataDir) + col := cell % 2 + row := cell / 2 + dest := image.Rect(col*collageCellSize, row*collageCellSize, (col+1)*collageCellSize, (row+1)*collageCellSize) + drawScaled(out, dest, img) + } + + if err := os.MkdirAll(filepath.Join(dataDir, "playlist_covers"), 0o755); err != nil { + return "", fmt.Errorf("mkdir playlist_covers: %w", err) + } + relPath := filepath.Join("playlist_covers", uuidToFilename(playlistID)+".jpg") + full := filepath.Join(dataDir, relPath) + tmp := full + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return "", fmt.Errorf("create collage file: %w", err) + } + if err := jpeg.Encode(f, out, &jpeg.Options{Quality: collageQuality}); err != nil { + _ = f.Close() + _ = os.Remove(tmp) + return "", fmt.Errorf("encode jpeg: %w", err) + } + if err := f.Close(); err != nil { + return "", fmt.Errorf("close collage file: %w", err) + } + if err := os.Rename(tmp, full); err != nil { + return "", fmt.Errorf("rename: %w", err) + } + + relPathPtr := relPath + if err := q.SetPlaylistCover(ctx, dbq.SetPlaylistCoverParams{ + ID: playlistID, + CoverPath: &relPathPtr, + }); err != nil { + return "", fmt.Errorf("set cover_path: %w", err) + } + return relPath, nil +} + +// loadCellImage tries to load the album cover at the given path. On any +// failure (empty path, missing file, decode error), returns the rendered +// fallback glyph as a 300x300 image. +func loadCellImage(coverPath, dataDir string) image.Image { + if coverPath == "" { + return fallbackGlyph() + } + full := coverPath + if !filepath.IsAbs(coverPath) { + full = filepath.Join(dataDir, coverPath) + } + f, err := os.Open(full) + if err != nil { + return fallbackGlyph() + } + defer f.Close() + img, _, err := image.Decode(f) + if err != nil { + return fallbackGlyph() + } + return img +} + +// fallbackGlyph returns a 300x300 image filled with the FabledSword +// "slate" surface tint and a centered solid box approximating the +// album-fallback glyph. Slice 1 trade-off: rasterizing the actual SVG +// at runtime requires a third-party SVG renderer (oksvg / etc.) and +// adds dependency + complexity. The solid placeholder reads as +// "this cell intentionally empty" without committing to SVG plumbing. +// A follow-up task can swap in real SVG rasterization. +func fallbackGlyph() image.Image { + img := image.NewRGBA(image.Rect(0, 0, collageCellSize, collageCellSize)) + bg := color.RGBA{0x2C, 0x31, 0x3A, 0xFF} // FabledSword slate + fg := color.RGBA{0x9C, 0x9A, 0x92, 0xFF} // FabledSword ash + draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src) + center := image.Rect(100, 100, 200, 200) + draw.Draw(img, center, &image.Uniform{C: fg}, image.Point{}, draw.Src) + return img +} + +// drawScaled copies src into dst.Rect, scaling with simple nearest-neighbor. +// stdlib lacks high-quality scaling; nearest-neighbor is fine for a +// 600x600 output where each cell is 300x300 — most album covers are +// already 300-1500 pixels and the visual loss is minor. +func drawScaled(dst draw.Image, r image.Rectangle, src image.Image) { + srcBounds := src.Bounds() + for y := r.Min.Y; y < r.Max.Y; y++ { + for x := r.Min.X; x < r.Max.X; x++ { + sx := srcBounds.Min.X + (x-r.Min.X)*srcBounds.Dx()/r.Dx() + sy := srcBounds.Min.Y + (y-r.Min.Y)*srcBounds.Dy()/r.Dy() + dst.Set(x, y, src.At(sx, sy)) + } + } +} + +// uuidToFilename renders a pgtype.UUID for use in a filename. The +// strings.NewReplacer guards against any future caller passing a +// non-canonical form — the canonical 8-4-4-4-12 hex form has no path +// separators or .. sequences, but defense-in-depth is cheap. +func uuidToFilename(u pgtype.UUID) string { + s := pgtypeUUIDStringForCollage(u) + return strings.NewReplacer("/", "_", "..", "_").Replace(s) +} + +// pgtypeUUIDStringForCollage renders a pgtype.UUID as the canonical +// 8-4-4-4-12 hex form. Local helper to avoid pulling in a third-party +// UUID package or creating a cross-package dependency. +func pgtypeUUIDStringForCollage(u pgtype.UUID) string { + if !u.Valid { + return "" + } + b := u.Bytes + return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]) +} diff --git a/internal/playlists/collage_test.go b/internal/playlists/collage_test.go new file mode 100644 index 00000000..44e2431e --- /dev/null +++ b/internal/playlists/collage_test.go @@ -0,0 +1,75 @@ +package playlists_test + +import ( + "context" + "image" + _ "image/jpeg" + "os" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" +) + +func TestGenerateCollage_EmptyPlaylist(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + dir := t.TempDir() + svc := playlists.NewService(pool, nil, dir) + + pl, err := svc.Create(context.Background(), user.ID, "Empty", "", false) + if err != nil { + t.Fatalf("Create: %v", err) + } + + relPath, err := playlists.GenerateCollage(context.Background(), pool, pl.ID, dir) + if err != nil { + t.Fatalf("GenerateCollage: %v", err) + } + if relPath != "" { + t.Errorf("relPath = %q, want \"\" for empty playlist", relPath) + } +} + +func TestGenerateCollage_OneTrack(t *testing.T) { + pool := newPool(t) + user := seedUser(t, pool, "alice") + tk := seedTrack(t, pool, "Roygbiv", "BoC") + dir := t.TempDir() + svc := playlists.NewService(pool, nil, dir) + + pl, err := svc.Create(context.Background(), user.ID, "One", "", false) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID}); err != nil { + t.Fatalf("AppendTracks: %v", err) + } + + got, err := svc.Get(context.Background(), user.ID, pl.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.CoverPath == nil || *got.CoverPath == "" { + t.Fatalf("CoverPath empty after append") + } + + full := filepath.Join(dir, *got.CoverPath) + f, err := os.Open(full) + if err != nil { + t.Fatalf("open collage: %v", err) + } + defer f.Close() + img, format, err := image.Decode(f) + if err != nil { + t.Fatalf("decode collage: %v", err) + } + if format != "jpeg" { + t.Errorf("format = %q, want jpeg", format) + } + if img.Bounds().Dx() != 600 || img.Bounds().Dy() != 600 { + t.Errorf("dimensions = %dx%d, want 600x600", img.Bounds().Dx(), img.Bounds().Dy()) + } +} diff --git a/internal/playlists/service.go b/internal/playlists/service.go index 19ec8bd8..b08300f0 100644 --- a/internal/playlists/service.go +++ b/internal/playlists/service.go @@ -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, diff --git a/internal/playlists/service_test.go b/internal/playlists/service_test.go index 6d55fb15..1b8f6ec2 100644 --- a/internal/playlists/service_test.go +++ b/internal/playlists/service_test.go @@ -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) + } +} diff --git a/internal/playlists/service_test_helpers_test.go b/internal/playlists/service_test_helpers_test.go index 91b7ce8c..a50118d0 100644 --- a/internal/playlists/service_test_helpers_test.go +++ b/internal/playlists/service_test_helpers_test.go @@ -3,9 +3,12 @@ package playlists_test import ( "context" "crypto/rand" + "fmt" "io" "log/slog" "os" + "path/filepath" + "sync/atomic" "testing" "github.com/jackc/pgx/v5/pgtype" @@ -16,6 +19,12 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" ) +// seedTrackCounter ensures every seedTrack call gets a unique file_path +// even when title + artist repeat. Tracks dedupe by file_path so we +// must vary it; using t.TempDir() alone isn't enough because some +// tests seed multiple tracks within one test function. +var seedTrackCounter uint64 + // newPool migrates a fresh schema against MINSTREL_TEST_DATABASE_URL // and returns a connection pool with all data tables wiped (test // users only — see internal/dbtest/reset.go for the why). Skips when @@ -58,6 +67,40 @@ func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User { return u } +// seedTrack creates an artist + album + track triple suitable for +// playlist track-list mutation tests. Each call produces a fresh +// track row with a unique file_path; artist and album are not +// deduplicated across calls (mbid-less upsert), but that's fine for +// these tests — playlist snapshots just capture the strings. +func seedTrack(t *testing.T, pool *pgxpool.Pool, title, artist string) dbq.Track { + t.Helper() + q := dbq.New(pool) + a, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: artist, SortName: artist, + }) + if err != nil { + t.Fatalf("seed artist: %v", err) + } + al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: artist + " - Album", SortTitle: artist + " - Album", + ArtistID: a.ID, + }) + if err != nil { + t.Fatalf("seed album: %v", err) + } + n := atomic.AddUint64(&seedTrackCounter, 1) + track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: title, AlbumID: al.ID, ArtistID: a.ID, + DurationMs: 1000, + FilePath: filepath.Join(t.TempDir(), fmt.Sprintf("%s-%d.mp3", title, n)), + FileSize: 100, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("seed track: %v", err) + } + return track +} + // randomUUID returns a fresh valid pgtype.UUID. Used in tests that // need an id guaranteed-not-to-exist in the database. func randomUUID() pgtype.UUID {