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:
@@ -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 <dataDir>/playlist_covers/<playlist_id>.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])
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user