Files
silo-server/internal/scanner/audiobook_cover.go
1664c60425 fix(metadata): publish artwork revisions atomically (#399)
* fix(metadata): publish artwork revisions atomically

* fix(metadata): harden artwork revision cleanup

* fix(metadata): address artwork revision review findings

- restore image applies for all media_items types and reject unsupported
  target/image combinations with 400 before uploading; episodes coerce to
  stills and the web dialog no longer offers image tabs episodes can't use
- add WHEN clauses to displacement triggers and hoist to_jsonb so bulk
  catalog upserts that assign unchanged artwork columns skip the trigger
- make artworkkey the single variant-ladder owner: imagecache derives its
  widths from it and triggers store image_type instead of hardcoded
  variant arrays, expanded by the collector at deletion time
- sweep dormant registry rows periodically so references lost through
  untriggered surfaces degrade to slow cleanup instead of leaking
- park just-published revisions dormant, keep dormant rows dormant on
  re-cache, and batch the GC reference pre-check per run
- heal rows re-referencing a just-deleted revision via reconciler-style
  resets after the deletion commits
- share a per-URL image-loaded hook across DetailHero, ItemCard,
  SectionItemCard, GlobalSearch, and CollectionPosterCard
- deduplicate Cache/CacheBytes finalization and drop unused VariantPaths
  plumbing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): cast reused timestamp parameter in revision upsert

Postgres cannot deduce one type for $3 used both as a plain value and
inside a CASE arm; the dev deploy surfaced it as SQLSTATE 42P08 on every
publication. Cast both uses and cover the arm/park/track upserts with
database-backed tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(metadata): address artwork revision review comments

- keep a durable heal path: deletion marks deleted_at instead of removing
  the registry row, so a failed post-delete heal retries with backoff and
  broken references never park; trackers clear the marker on re-upload
- never treat bare existence as an immutable-content match; backends
  without content verification rewrite the object
- exercise revisioned cover keys in scanner/enrichment fakes, compare the
  tracked manifest exactly, and honor cancellation in the blocking test
  deleter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:33:21 -04:00

192 lines
5.5 KiB
Go

package scanner
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/Silo-Server/silo-server/internal/catalog"
)
const maxAudiobookSidecarCoverSize = 8 * 1024 * 1024
// audiobookCoverCacher is the narrow slice of imagecache.Cacher the
// audiobook scanner uses. Defined with scalar args (not the imagecache
// request struct) to avoid an import cycle: imagecache imports metadata
// which imports scanner.
type audiobookCoverCacher interface {
CacheAudiobookCover(ctx context.Context, data []byte, contentID string) (storedPath string, thumbhash string, err error)
}
type ebookCoverCacher interface {
CacheEbookCover(ctx context.Context, data []byte, contentID string) (storedPath string, thumbhash string, err error)
}
type audiobookCoverMetadataStore interface {
GetPosterPath(ctx context.Context, contentID string) (string, error)
UpdateMetadata(ctx context.Context, contentID string, upd *catalog.MetadataUpdate) error
}
// FFmpegPathFromFFprobe derives the ffmpeg binary path from a configured
// ffprobe path. They live side by side in every silo deployment.
func FFmpegPathFromFFprobe(ffprobePath string) string {
if ffprobePath == "" {
return ""
}
if i := strings.LastIndex(ffprobePath, "ffprobe"); i >= 0 {
candidate := ffprobePath[:i] + "ffmpeg" + ffprobePath[i+len("ffprobe"):]
if candidate != "" && candidate != ffprobePath {
return candidate
}
}
return ""
}
// ExtractAndUploadAudiobookCover reads the embedded cover image (if any)
// from the given audio file via ffmpeg, pushes it through the silo
// imagecache (resize + thumbhash + S3 upload), and returns the
// poster_path S3 key plus thumbhash. Returns "", "" (no error) when no
// embedded cover exists or the cacher is unavailable.
func ExtractAndUploadAudiobookCover(
ctx context.Context,
ffmpegPath string,
cacher audiobookCoverCacher,
audioFilePath string,
contentID string,
) (string, string) {
if ffmpegPath == "" || cacher == nil || audioFilePath == "" || contentID == "" {
return "", ""
}
cmd := exec.CommandContext(ctx, ffmpegPath,
"-loglevel", "error",
"-i", audioFilePath,
"-an", "-map", "0:v?", "-c:v", "mjpeg",
"-frames:v", "1",
"-f", "mjpeg",
"pipe:1",
)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
slog.DebugContext(ctx, "audiobook cover: ffmpeg failed (likely no embedded artwork)", "component", "scanner",
"path", audioFilePath, "error", err, "stderr", stderr.String())
return "", ""
}
data := stdout.Bytes()
if len(data) == 0 {
return "", ""
}
storedPath, thumbhash, err := cacher.CacheAudiobookCover(ctx, data, contentID)
if err != nil {
slog.WarnContext(ctx, "audiobook cover: imagecache upload failed", "component", "scanner",
"path", audioFilePath, "error", err)
return "", ""
}
return storedPath, thumbhash
}
func applyAudiobookSidecarCover(ctx context.Context, store audiobookCoverMetadataStore, cacher audiobookCoverCacher, contentID string, folderPath string) error {
if store == nil || cacher == nil || contentID == "" || folderPath == "" {
return nil
}
cover, _, err := findSidecarAudiobookCover(folderPath)
if err != nil || len(cover) == 0 {
return err
}
existingPosterPath, err := store.GetPosterPath(ctx, contentID)
if err != nil {
return fmt.Errorf("get audiobook poster path for cover: %w", err)
}
if strings.TrimSpace(existingPosterPath) != "" {
return nil
}
posterPath, thumbhash, err := cacher.CacheAudiobookCover(ctx, cover, contentID)
if err != nil {
return err
}
update := &catalog.MetadataUpdate{PosterPath: &posterPath}
if thumbhash != "" {
update.PosterThumbhash = &thumbhash
}
return store.UpdateMetadata(ctx, contentID, update)
}
var sidecarAudiobookCoverNames = []string{"cover", "folder", "front", "poster", "thumbnail"}
var sidecarAudiobookCoverExtensions = []string{".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".bmp"}
func findSidecarAudiobookCover(dir string) ([]byte, string, error) {
if dir == "" {
return nil, "", nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, "", err
}
byName := make(map[string]string, len(entries))
for _, entry := range entries {
if !isRegularDirEntry(entry) {
continue
}
byName[strings.ToLower(entry.Name())] = filepath.Join(dir, entry.Name())
}
for _, name := range sidecarAudiobookCoverNames {
for _, ext := range sidecarAudiobookCoverExtensions {
path := byName[name+ext]
if path == "" {
continue
}
data, err := readSidecarAudiobookCover(path)
if err != nil {
return nil, path, err
}
return data, path, nil
}
}
return nil, "", nil
}
func isRegularDirEntry(entry os.DirEntry) bool {
if entry == nil || entry.IsDir() || entry.Type()&os.ModeSymlink != 0 {
return false
}
info, err := entry.Info()
if err != nil {
return false
}
return info.Mode().IsRegular()
}
func readSidecarAudiobookCover(path string) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if info.Size() > maxAudiobookSidecarCoverSize {
return nil, fmt.Errorf("audiobook sidecar cover too large: %s", path)
}
data, err := io.ReadAll(io.LimitReader(file, maxAudiobookSidecarCoverSize+1))
if err != nil {
return nil, err
}
if len(data) > maxAudiobookSidecarCoverSize {
return nil, fmt.Errorf("audiobook sidecar cover too large: %s", path)
}
if len(data) == 0 {
return nil, fmt.Errorf("audiobook sidecar cover empty: %s", path)
}
return data, nil
}