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) // Pull more rows than cells so we can dedupe identical covers and // still fill every cell. A 25-track "Songs like X" playlist that // has 5 tracks each from 5 albums shouldn't render as four // identical cells of the first album's cover; pulling 50 and // deduping by cover_art_path gives us four distinct covers in // realistic libraries. allRows, err := q.ListAllPlaylistTracksForCollage(ctx, dbq.ListAllPlaylistTracksForCollageParams{ PlaylistID: playlistID, Limit: 50, }) if err != nil { return "", fmt.Errorf("list collage tracks: %w", err) } rows := dedupeCollageRows(allRows, 4) 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 } // dedupeCollageRows trims the input to at most max rows such that no // non-empty cover_art_path appears twice. Rows with NULL or empty // cover_art_path are kept as-is — they each render the fallback glyph // in their own cell, which is the right behavior (a partially-covered // playlist still gets a sensible visual rather than collapsing every // missing cover into a single cell). // // Preserves input order (which is playlist position order from the SQL). func dedupeCollageRows(rows []dbq.ListAllPlaylistTracksForCollageRow, max int) []dbq.ListAllPlaylistTracksForCollageRow { seen := make(map[string]bool, max) out := make([]dbq.ListAllPlaylistTracksForCollageRow, 0, max) for _, r := range rows { if len(out) >= max { break } if r.AlbumCoverPath == nil || *r.AlbumCoverPath == "" { out = append(out, r) continue } if seen[*r.AlbumCoverPath] { continue } seen[*r.AlbumCoverPath] = true out = append(out, r) } return out } // 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 func() { _ = 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 using a center-cropped "cover" fit // (the same model as BoxFit.cover / object-fit: cover in the clients). // Non-square sources are scaled so the *smaller* destination dimension is // fully filled and the larger axis is center-cropped, preserving aspect // ratio. Without this, banner-shaped or LP-shaped album art stretches in // the cell -- the album-coherent system playlists (new_for_you, // first_listens) make the stretching disproportionately visible because // fewer unique covers contribute, so each warped cell is a quarter of // the collage rather than diluted. // // Scaling itself stays nearest-neighbor -- stdlib lacks high-quality // scaling and dependency cost is unjustified for this 600x600 output. func drawScaled(dst draw.Image, r image.Rectangle, src image.Image) { srcBounds := src.Bounds() srcW := srcBounds.Dx() srcH := srcBounds.Dy() if srcW <= 0 || srcH <= 0 { return } dstW := r.Dx() dstH := r.Dy() // Cover-fit: the side of src that maps to dst at the larger scale // fully fills its axis; the other axis is center-cropped. scaleX := float64(dstW) / float64(srcW) scaleY := float64(dstH) / float64(srcH) scale := scaleX if scaleY > scale { scale = scaleY } cropW := float64(dstW) / scale cropH := float64(dstH) / scale cropOffX := float64(srcBounds.Min.X) + (float64(srcW)-cropW)/2 cropOffY := float64(srcBounds.Min.Y) + (float64(srcH)-cropH)/2 for y := r.Min.Y; y < r.Max.Y; y++ { sy := int(cropOffY + float64(y-r.Min.Y)*cropH/float64(dstH)) for x := r.Min.X; x < r.Max.X; x++ { sx := int(cropOffX + float64(x-r.Min.X)*cropW/float64(dstW)) 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]) }