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])
|
||||
}
|
||||
Reference in New Issue
Block a user