pruneEmptyEntities deleted empty albums but never the cover_art rows they referenced, so removing the last track of an album leaked the row and its files forever — the janitor's covers sweep computes its live set from cover_art.file_path, which keeps the orphaned row's files exempt. Extract sweepOrphanedCoverArt/removeCoverArtFiles as one implementation and run it from pruneEmptyEntities (scan orphan path and RemoveFromLibrary) and RemoveLibrary alike. Closes #247
320 lines
8.3 KiB
Go
320 lines
8.3 KiB
Go
package library
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"image"
|
|
"image/jpeg"
|
|
_ "image/png" // Register PNG decoder.
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"golang.org/x/image/draw"
|
|
|
|
"yellowjacket/backend/coverart"
|
|
"yellowjacket/backend/metadata"
|
|
)
|
|
|
|
// thumbnailTier defines a single size tier for generated cover art thumbnails.
|
|
type thumbnailTier struct {
|
|
// Suffix appended to the content hash (e.g. "_sm", "_md", "_lg").
|
|
Suffix string
|
|
// MaxSize is the maximum width or height in pixels.
|
|
MaxSize int
|
|
// Quality is the JPEG encoding quality (1-100).
|
|
Quality int
|
|
}
|
|
|
|
// thumbnailWork is a unit of work for the async thumbnail worker pool.
|
|
type thumbnailWork struct {
|
|
imgData []byte
|
|
dir string
|
|
hashStr string
|
|
metrics *ScanMetrics
|
|
}
|
|
|
|
// thumbnailTiers lists all generated size variants, ordered smallest to largest.
|
|
var thumbnailTiers = []thumbnailTier{
|
|
{Suffix: "_sm", MaxSize: 100, Quality: 75},
|
|
{Suffix: "_md", MaxSize: 200, Quality: 80},
|
|
{Suffix: "_lg", MaxSize: 400, Quality: 85},
|
|
}
|
|
|
|
// largestTier is the tier stored as the cover's canonical file.
|
|
func largestTier() thumbnailTier {
|
|
return thumbnailTiers[len(thumbnailTiers)-1]
|
|
}
|
|
|
|
// CoverArtFileSet returns every file on disk belonging to one cover art
|
|
// entry: each generated size variant.
|
|
//
|
|
// Only one of them is recorded in cover_art.file_path — the others are
|
|
// derived filenames — so any code deleting cover art has to expand the
|
|
// set or the rest are orphaned.
|
|
func CoverArtFileSet(coverPath string) []string {
|
|
dir := filepath.Dir(coverPath)
|
|
base := filepath.Base(coverPath)
|
|
|
|
paths := make([]string, 0, len(thumbnailTiers))
|
|
|
|
for _, tier := range thumbnailTiers {
|
|
paths = append(paths, filepath.Join(
|
|
dir, coverart.SizedFilename(base, tier.Suffix),
|
|
))
|
|
}
|
|
|
|
return paths
|
|
}
|
|
|
|
// sweepOrphanedCoverArt deletes the cover_art rows no album references
|
|
// and returns their file paths, for the caller to remove from disk
|
|
// after the transaction commits. Cover art is referenced only by
|
|
// albums.cover_art_id, so an orphan is a cover whose album is gone —
|
|
// which is every album the caller just swept.
|
|
//
|
|
// One implementation because the scan path, RemoveFromLibrary and
|
|
// RemoveLibrary all reach this state, and the scan side used to skip it
|
|
// entirely while RemoveLibrary did it inline (#247).
|
|
func (l *Library) sweepOrphanedCoverArt(tx *sql.Tx) ([]string, error) {
|
|
const orphanSQL = `
|
|
SELECT file_path FROM cover_art WHERE id NOT IN (
|
|
SELECT DISTINCT cover_art_id FROM albums
|
|
WHERE cover_art_id IS NOT NULL
|
|
)`
|
|
|
|
rows, err := tx.QueryContext(l.ctx, orphanSQL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("could not query orphaned cover art: %w", err)
|
|
}
|
|
|
|
var paths []string
|
|
|
|
for rows.Next() {
|
|
var filePath string
|
|
|
|
if err := rows.Scan(&filePath); err != nil {
|
|
l.logger.Warn("could not scan cover art path", "err", err)
|
|
|
|
continue
|
|
}
|
|
|
|
paths = append(paths, filePath)
|
|
}
|
|
|
|
// Close before the DELETE: the two run on the one writer connection.
|
|
if err := rows.Close(); err != nil {
|
|
l.logger.Warn("could not close cover art rows", "err", err)
|
|
}
|
|
|
|
if len(paths) > 0 {
|
|
if _, err := tx.ExecContext(l.ctx, `
|
|
DELETE FROM cover_art WHERE id NOT IN (
|
|
SELECT DISTINCT cover_art_id FROM albums
|
|
WHERE cover_art_id IS NOT NULL
|
|
)`); err != nil {
|
|
return nil, fmt.Errorf("could not delete orphaned cover_art: %w", err)
|
|
}
|
|
}
|
|
|
|
return paths, nil
|
|
}
|
|
|
|
// removeCoverArtFiles removes a cover original and its derived size
|
|
// variants. Only the original is stored in cover_art.file_path; the
|
|
// _sm/_md/_lg tiers are derived filenames beside it, so they have to be
|
|
// removed by name or they accumulate forever.
|
|
func (l *Library) removeCoverArtFiles(coverPaths []string) {
|
|
for _, coverPath := range coverPaths {
|
|
for _, path := range CoverArtFileSet(coverPath) {
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
l.logger.Warn(
|
|
"could not remove orphaned cover art file",
|
|
"path", path,
|
|
"err", err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// saveCoverArt saves embedded cover art to the cache directory.
|
|
// Returns the file path where the art was saved, or empty string
|
|
// if no picture data. Timing is recorded in the provided metrics.
|
|
// When thumbChan is non-nil, thumbnail generation is dispatched
|
|
// asynchronously to a worker pool instead of running inline.
|
|
func (l *Library) saveCoverArt(
|
|
pic *metadata.PictureData,
|
|
metrics *ScanMetrics,
|
|
thumbChan chan<- thumbnailWork,
|
|
) (string, error) {
|
|
if pic == nil || len(pic.Data) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
saveStart := time.Now()
|
|
|
|
// Get the covers directory for storing cover art.
|
|
coverDir, err := coverart.CoversDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"could not resolve covers directory: %w", err,
|
|
)
|
|
}
|
|
|
|
// Ensure directory exists.
|
|
if err := os.MkdirAll(coverDir, 0o755); err != nil {
|
|
return "", fmt.Errorf(
|
|
"could not create covers directory: %w", err,
|
|
)
|
|
}
|
|
|
|
// The content hash identifies the cover and dedupes it; the largest
|
|
// tier is what gets stored under that name.
|
|
//
|
|
// The full-resolution image used to be written here too, and it was
|
|
// 1,134 MB of a 1.4 GB covers directory on a real library - against
|
|
// 110 MB for all three tiers together - with nothing rendering it.
|
|
// The bytes are still in the audio file if a bigger one is ever
|
|
// needed, which is where these came from.
|
|
hash := sha256.Sum256(pic.Data)
|
|
hashStr := hex.EncodeToString(hash[:8]) // First 8 bytes = 16 hex chars.
|
|
|
|
filePath := filepath.Join(
|
|
coverDir, coverart.SizedFilename(hashStr, largestTier().Suffix),
|
|
)
|
|
|
|
// Skip if this cover has already been stored (same content hash).
|
|
if _, err := os.Stat(filePath); err == nil {
|
|
l.logger.Debug("cover art already stored", "path", filePath)
|
|
|
|
return filePath, nil
|
|
}
|
|
|
|
// Dispatch thumbnail generation to the async worker pool if
|
|
// available, otherwise generate inline.
|
|
if thumbChan != nil {
|
|
thumbChan <- thumbnailWork{
|
|
imgData: pic.Data,
|
|
dir: coverDir,
|
|
hashStr: hashStr,
|
|
metrics: metrics,
|
|
}
|
|
} else if err := l.generateSizedVariantsWithMetrics(
|
|
pic.Data, coverDir, hashStr, metrics,
|
|
); err != nil {
|
|
l.logger.Warn("could not generate sized variants",
|
|
"path", filePath, "err", err)
|
|
}
|
|
|
|
metrics.addCoverArtSave(time.Since(saveStart))
|
|
|
|
return filePath, nil
|
|
}
|
|
|
|
// generateSizedVariantsWithMetrics is like generateSizedVariants
|
|
// but records per-tier timing in the provided metrics.
|
|
func (l *Library) generateSizedVariantsWithMetrics(
|
|
imgData []byte,
|
|
dir, hashStr string,
|
|
metrics *ScanMetrics,
|
|
) error {
|
|
src, _, err := image.Decode(bytes.NewReader(imgData))
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"could not decode image for thumbnails: %w", err,
|
|
)
|
|
}
|
|
|
|
bounds := src.Bounds()
|
|
srcW := bounds.Dx()
|
|
srcH := bounds.Dy()
|
|
|
|
for _, tier := range thumbnailTiers {
|
|
tierStart := time.Now()
|
|
|
|
tierPath := filepath.Join(
|
|
dir,
|
|
fmt.Sprintf("%s%s.jpg", hashStr, tier.Suffix),
|
|
)
|
|
|
|
w, h := fitDimensions(srcW, srcH, tier.MaxSize)
|
|
|
|
if err := encodeAndSaveImage(
|
|
src, tierPath, w, h, tier.Quality,
|
|
); err != nil {
|
|
l.logger.Warn(
|
|
"could not generate sized variant",
|
|
"tier", tier.Suffix,
|
|
"path", tierPath,
|
|
"err", err,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
metrics.addThumbnailTier(
|
|
tier.Suffix, time.Since(tierStart),
|
|
)
|
|
|
|
l.logger.Debug(
|
|
"saved sized variant",
|
|
"tier", tier.Suffix,
|
|
"path", tierPath,
|
|
"dimensions", fmt.Sprintf("%dx%d", w, h),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// fitDimensions calculates the output dimensions that fit within maxSize
|
|
// while preserving the aspect ratio. If the source is already smaller
|
|
// than maxSize, the original dimensions are returned unchanged.
|
|
func fitDimensions(srcW, srcH, maxSize int) (int, int) {
|
|
if srcW <= maxSize && srcH <= maxSize {
|
|
return srcW, srcH
|
|
}
|
|
|
|
w, h := maxSize, maxSize
|
|
if srcW > srcH {
|
|
h = srcH * maxSize / srcW
|
|
} else {
|
|
w = srcW * maxSize / srcH
|
|
}
|
|
|
|
return w, h
|
|
}
|
|
|
|
// encodeAndSaveImage scales the source image to the given dimensions
|
|
// and saves it as a JPEG with the specified quality.
|
|
func encodeAndSaveImage(
|
|
src image.Image,
|
|
path string,
|
|
w, h, quality int,
|
|
) error {
|
|
dst := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
draw.ApproxBiLinear.Scale(
|
|
dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil,
|
|
)
|
|
|
|
var buf bytes.Buffer
|
|
|
|
if err := jpeg.Encode(
|
|
&buf, dst, &jpeg.Options{Quality: quality},
|
|
); err != nil {
|
|
return fmt.Errorf("could not encode image: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(
|
|
path, buf.Bytes(), 0o644,
|
|
); err != nil {
|
|
return fmt.Errorf("could not write image: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|