feat(audiobooks): add local-file cover fallback in enricher

Replaces the inline cover extraction removed from the scan path.
Pulls the embedded cover from the primary audio file via ffmpeg when
the provider chain didn't supply a poster_path.
This commit is contained in:
RXWatcher
2026-05-27 09:12:29 +02:00
parent bbc65baabc
commit 95f77f4f73
+62
View File
@@ -25,8 +25,16 @@ import (
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanner"
)
// audiobookCoverCacher is a narrow shape matching scanner.audiobookCoverCacher's
// method set by structural typing. Anything implementing CacheAudiobookCover can
// be passed to scanner.ExtractAndUploadAudiobookCover via this interface.
type audiobookCoverCacher interface {
CacheAudiobookCover(ctx context.Context, data []byte, contentID string) (basePath string, ext string, thumbhash string, err error)
}
const (
// defaultEnrichBatchSize is the maximum number of audiobook items processed
// per sweep invocation. Keeps latency bounded for large libraries.
@@ -53,6 +61,8 @@ type Enricher struct {
itemRepo *catalog.ItemRepository
personRepo *catalog.PersonRepository
providerIDs *catalog.ProviderIDRepository
imageCacher audiobookCoverCacher
ffmpegPath string
batchSize int
}
@@ -66,6 +76,8 @@ func NewEnricher(
itemRepo *catalog.ItemRepository,
personRepo *catalog.PersonRepository,
providerIDs *catalog.ProviderIDRepository,
imageCacher audiobookCoverCacher,
ffmpegPath string,
) *Enricher {
return &Enricher{
pool: pool,
@@ -74,6 +86,8 @@ func NewEnricher(
itemRepo: itemRepo,
personRepo: personRepo,
providerIDs: providerIDs,
imageCacher: imageCacher,
ffmpegPath: ffmpegPath,
batchSize: defaultEnrichBatchSize,
}
}
@@ -333,6 +347,15 @@ func (e *Enricher) enrichItem(ctx context.Context, item enrichmentItemRow) error
"overview", accumulator.Overview != "",
"people", len(accumulator.People),
)
if e.imageCacher != nil && e.ffmpegPath != "" {
if err := e.applyLocalCoverFallback(ctx, item.ContentID); err != nil {
slog.Warn("audiobook enrichment: local cover fallback failed",
"content_id", item.ContentID,
"error", err,
)
}
}
return nil
}
@@ -470,6 +493,45 @@ func (e *Enricher) persistPeople(ctx context.Context, contentID string, people [
return e.itemRepo.ReplacePeople(ctx, contentID, linked)
}
// applyLocalCoverFallback reads the embedded cover from the audiobook's
// primary audio file and writes it to poster_path if (and only if) the
// item still has no poster after provider enrichment. Best-effort; errors
// are logged by the caller, not returned to fail the sweep.
func (e *Enricher) applyLocalCoverFallback(ctx context.Context, contentID string) error {
var posterPath string
var primaryFile string
err := e.pool.QueryRow(ctx, `
SELECT COALESCE(mi.poster_path, ''), COALESCE(mf.file_path, '')
FROM media_items mi
LEFT JOIN LATERAL (
SELECT file_path FROM media_files
WHERE content_id = mi.content_id
ORDER BY id ASC
LIMIT 1
) mf ON TRUE
WHERE mi.content_id = $1
`, contentID).Scan(&posterPath, &primaryFile)
if err != nil {
return fmt.Errorf("read item for cover fallback: %w", err)
}
if posterPath != "" || primaryFile == "" {
return nil
}
poster, thumb := scanner.ExtractAndUploadAudiobookCover(ctx, e.ffmpegPath, e.imageCacher, primaryFile, contentID)
if poster == "" {
return nil
}
if _, err := e.pool.Exec(ctx, `
UPDATE media_items
SET poster_path = $1, poster_thumbhash = $2, updated_at = NOW()
WHERE content_id = $3 AND (poster_path IS NULL OR poster_path = '')
`, poster, thumb, contentID); err != nil {
return fmt.Errorf("update poster_path: %w", err)
}
return nil
}
// mergeEnrichmentProviderIDs copies new provider IDs from src into dst,
// without overwriting existing entries.
func mergeEnrichmentProviderIDs(dst *metadata.MetadataResult, src *metadata.MetadataResult) {