fix(audiobooks): extract scan covers and harden dedupe (#103)

* fix(audiobooks): extract scan covers and harden dedupe

* fix(audiobooks): address scan cover review

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
RXWatcher
2026-06-16 17:58:01 -04:00
committed by GitHub
co-authored by rxwatcher Quick
parent fe7eb70483
commit bf54f040f9
4 changed files with 351 additions and 38 deletions
+23
View File
@@ -32,6 +32,7 @@ var unabridgedTokenRE = regexp.MustCompile(`(?i)\s*\(unabridged\)\s*`)
// Used after the strip passes since removing a mid-string token can
// leave double spaces behind.
var collapseSpacesRE = regexp.MustCompile(`\s+`)
var audiobookDedupeTitleTokenRE = regexp.MustCompile(`[^A-Za-z0-9]+`)
// stripNarratorSuffix removes the narrator-suffix noise and "(unabridged)"
// markers from a title. Returns the input unchanged when no match.
@@ -44,6 +45,28 @@ func stripNarratorSuffix(title string) string {
return strings.TrimSpace(cleaned)
}
func normalizeAudiobookDedupeTitle(title string) string {
title = stripNarratorSuffix(title)
title = audiobookDedupeTitleTokenRE.ReplaceAllString(title, " ")
title = collapseSpacesRE.ReplaceAllString(title, " ")
return strings.ToLower(strings.TrimSpace(title))
}
func audiobookDedupeTitlesMatch(a, b string) bool {
left := normalizeAudiobookDedupeTitle(a)
right := normalizeAudiobookDedupeTitle(b)
if left == "" || right == "" {
return false
}
if left == right {
return true
}
if len(left) < len(right) {
return strings.HasPrefix(right, left+" ")
}
return strings.HasPrefix(left, right+" ")
}
// parsedAudiobook is the structured output of parseAudiobookFolder.
// The scanner write path (Task 8) converts this into media_items +
// media_files + item_people rows.
+148 -38
View File
@@ -18,6 +18,7 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/titleutil"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type filesystemRootContentFinder interface {
@@ -28,6 +29,44 @@ type filesystemMediaItemWriter interface {
Upsert(ctx context.Context, item *models.MediaItem) error
}
type audiobookPosterExec interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
type audiobookPosterPathReader interface {
GetPosterPath(ctx context.Context, contentID string) (string, error)
}
const audiobookDuplicateCandidateSQL = `
SELECT mi.content_id, mi.title
FROM media_items mi
JOIN item_people ipa ON ipa.content_id = mi.content_id AND ipa.kind = 7
JOIN people pa ON pa.id = ipa.person_id AND LOWER(pa.name) = LOWER($1)
JOIN item_people ipn ON ipn.content_id = mi.content_id AND ipn.kind = 8
JOIN people pn ON pn.id = ipn.person_id AND LOWER(pn.name) = LOWER($2)
JOIN LATERAL (
SELECT COALESCE(SUM(mf.duration), 0) AS dur
FROM media_files mf WHERE mf.content_id = mi.content_id
) f ON TRUE
JOIN LATERAL (
SELECT btrim(regexp_replace(
regexp_replace(lower(mi.title), '[^a-z0-9]+', ' ', 'g'),
'[[:space:]]+', ' ', 'g'
)) AS normalized_title
) t ON TRUE
WHERE mi.type = 'audiobook'
AND mi.year = $3
AND f.dur > 0
AND ABS(f.dur - $4) <= $5
AND (
t.normalized_title = $6
OR t.normalized_title LIKE $6 || ' %'
OR $6 LIKE t.normalized_title || ' %'
)
ORDER BY ABS(f.dur - $4), LENGTH(mi.title), mi.content_id
LIMIT 25
`
// audiobookDiskFile is the on-disk projection used by audiobookFolderUnchanged.
// Path is the absolute file path; Size and ModTime come from os.Stat.
type audiobookDiskFile struct {
@@ -324,6 +363,16 @@ func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.M
"error", err,
)
}
if len(parsed.Files) > 0 && s.fileRepo != nil {
if _, err := applyAudiobookEmbeddedCover(ctx, s.itemRepo, s.fileRepo.Pool(), FFmpegPathFromFFprobe(s.ffprobePath), s.imageCacher, parsed.Files[0].Path, contentID); err != nil {
slog.Warn("audiobook scan: embedded cover failed",
"folder_id", folder.ID,
"content_id", contentID,
"path", parsed.Files[0].Path,
"error", err,
)
}
}
if err := s.upsertAudiobookPeople(ctx, contentID, parsed); err != nil {
return fmt.Errorf("upsert audiobook people: %w", err)
}
@@ -360,6 +409,42 @@ func (s *Scanner) reconcileAudiobookFolder(ctx context.Context, folder *models.M
return nil
}
func applyAudiobookEmbeddedCover(
ctx context.Context,
reader audiobookPosterPathReader,
exec audiobookPosterExec,
ffmpegPath string,
cacher audiobookCoverCacher,
audioFilePath string,
contentID string,
) (bool, error) {
if exec == nil {
return false, nil
}
if reader != nil {
existingPosterPath, err := reader.GetPosterPath(ctx, contentID)
if err != nil {
return false, fmt.Errorf("get audiobook poster path for embedded cover: %w", err)
}
if strings.TrimSpace(existingPosterPath) != "" {
return false, nil
}
}
poster, thumb := ExtractAndUploadAudiobookCover(ctx, ffmpegPath, cacher, audioFilePath, contentID)
if poster == "" {
return false, nil
}
_, err := exec.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)
if err != nil {
return false, fmt.Errorf("update audiobook embedded cover: %w", err)
}
return true, nil
}
// upsertAudiobookMediaItem reuses an item already linked to the same filesystem
// root, then falls back to the stricter ABS duplicate rule, or creates a new row.
// It intentionally avoids title/year-only dedupe because audiobooks often share
@@ -385,7 +470,7 @@ func (s *Scanner) upsertAudiobookMediaItem(ctx context.Context, folderID int, fo
cleanTitle = book.Title
}
if existing := s.findAudiobookByFilePath(ctx, book); existing != nil {
if existing := s.findAudiobookByFilePath(ctx, folderID, book); existing != nil {
applyBookToMediaItem(existing, book)
if existing.SortTitle == "" {
existing.SortTitle = titleutil.DeriveDefaultSortTitle(existing.Title)
@@ -469,10 +554,10 @@ func createAudiobookMediaItem(ctx context.Context, itemWriter filesystemMediaIte
// findAudiobookDuplicate returns an existing audiobook media_items row that
// matches the parsed book on (author, narrator, year, duration ±0.5%/±10s,
// title-prefix). Used after the file-path lookup misses to detect the same
// recording stored under a different folder name. Returns nil when no match
// exists or any required attribute (author, narrator, year, duration) is
// missing — under-tagged files don't qualify for automatic dedup.
// punctuation-normalized title). Used after the file-path lookup misses to
// detect the same recording stored under a different folder name. Returns nil
// when no match exists or any required attribute (author, narrator, year,
// duration) is missing — under-tagged files don't qualify for automatic dedup.
func (s *Scanner) findAudiobookDuplicate(ctx context.Context, book *parsedAudiobook, cleanTitle string) *models.MediaItem {
if s.fileRepo == nil {
return nil
@@ -491,32 +576,39 @@ func (s *Scanner) findAudiobookDuplicate(ctx context.Context, book *parsedAudiob
if tolerance < 10 {
tolerance = 10
}
var existingID string
err := s.fileRepo.Pool().QueryRow(ctx, `
SELECT mi.content_id
FROM media_items mi
JOIN item_people ipa ON ipa.content_id = mi.content_id AND ipa.kind = 7
JOIN people pa ON pa.id = ipa.person_id AND pa.name = $1
JOIN item_people ipn ON ipn.content_id = mi.content_id AND ipn.kind = 8
JOIN people pn ON pn.id = ipn.person_id AND pn.name = $2
JOIN LATERAL (
SELECT COALESCE(SUM(mf.duration), 0) AS dur
FROM media_files mf WHERE mf.content_id = mi.content_id
) f ON TRUE
WHERE mi.type = 'audiobook'
AND mi.year = $3
AND f.dur > 0
AND ABS(f.dur - $4) <= $5
AND (
LOWER(mi.title) = LOWER($6)
OR (LENGTH(mi.title) > LENGTH($6) AND LOWER(mi.title) LIKE LOWER($6) || ':%')
OR (LENGTH($6) > LENGTH(mi.title) AND LOWER($6) LIKE LOWER(mi.title) || ':%')
)
LIMIT 1
`, book.Author, book.Narrator, book.Year, totalDuration, tolerance, cleanTitle).Scan(&existingID)
if err != nil || existingID == "" {
titleKey := normalizeAudiobookDedupeTitle(cleanTitle)
if titleKey == "" {
return nil
}
rows, err := s.fileRepo.Pool().Query(ctx,
audiobookDuplicateCandidateSQL,
book.Author,
book.Narrator,
book.Year,
totalDuration,
tolerance,
titleKey,
)
if err != nil {
return nil
}
defer rows.Close()
var existingID string
for rows.Next() {
var candidateID, candidateTitle string
if err := rows.Scan(&candidateID, &candidateTitle); err != nil {
return nil
}
if audiobookDedupeTitlesMatch(candidateTitle, cleanTitle) {
existingID = candidateID
break
}
}
if rows.Err() != nil || existingID == "" {
return nil
}
items, err := s.itemRepo.GetByIDs(ctx, []string{existingID})
if err != nil || len(items) == 0 {
return nil
@@ -527,16 +619,11 @@ func (s *Scanner) findAudiobookDuplicate(ctx context.Context, book *parsedAudiob
// findAudiobookByFilePath returns an existing audiobook media_items row
// whose media_files reference any of this book's audio file paths.
// Returns nil when no match exists (a fresh scan of a new folder).
func (s *Scanner) findAudiobookByFilePath(ctx context.Context, book *parsedAudiobook) *models.MediaItem {
func (s *Scanner) findAudiobookByFilePath(ctx context.Context, folderID int, book *parsedAudiobook) *models.MediaItem {
if s.fileRepo == nil || len(book.Files) == 0 {
return nil
}
paths := make([]string, 0, len(book.Files))
for _, f := range book.Files {
if f.Path != "" {
paths = append(paths, f.Path)
}
}
paths := audiobookLookupPaths(book.Files)
if len(paths) == 0 {
return nil
}
@@ -545,10 +632,11 @@ func (s *Scanner) findAudiobookByFilePath(ctx context.Context, book *parsedAudio
SELECT mf.content_id
FROM media_files mf
JOIN media_items mi ON mi.content_id = mf.content_id
WHERE mf.file_path = ANY($1)
WHERE mf.media_folder_id = $2
AND (mf.file_path = ANY($1) OR LOWER(mf.file_path) = ANY($1))
AND mi.type = 'audiobook'
LIMIT 1
`, paths).Scan(&existingID)
`, paths, folderID).Scan(&existingID)
if err != nil || existingID == "" {
return nil
}
@@ -559,6 +647,28 @@ func (s *Scanner) findAudiobookByFilePath(ctx context.Context, book *parsedAudio
return items[0]
}
func audiobookLookupPaths(files []parsedAudiobookFile) []string {
seen := make(map[string]struct{}, len(files)*2)
paths := make([]string, 0, len(files)*2)
for _, f := range files {
path := strings.TrimSpace(f.Path)
if path == "" {
continue
}
for _, candidate := range []string{path, strings.ToLower(path)} {
if candidate == "" {
continue
}
if _, ok := seen[candidate]; ok {
continue
}
seen[candidate] = struct{}{}
paths = append(paths, candidate)
}
}
return paths
}
// applyBookToMediaItem copies parsed-audiobook tag fields onto the
// MediaItem. Used for both fresh inserts and re-scans of existing rows
// so manual edits to fields not driven by the file (e.g., poster_path
+172
View File
@@ -11,6 +11,8 @@ import (
"testing"
"time"
"github.com/jackc/pgx/v5/pgconn"
"github.com/Silo-Server/silo-server/internal/models"
)
@@ -35,6 +37,139 @@ func (f *fakeFilesystemItemWriter) Upsert(_ context.Context, item *models.MediaI
return nil
}
type fakeAudiobookPosterExec struct {
calls int
query string
args []any
}
func (f *fakeAudiobookPosterExec) Exec(_ context.Context, query string, args ...any) (pgconn.CommandTag, error) {
f.calls++
f.query = query
f.args = append([]any(nil), args...)
return pgconn.CommandTag{}, nil
}
type fakeAudiobookPosterReader struct {
posterPath string
err error
calls int
}
func (f *fakeAudiobookPosterReader) GetPosterPath(context.Context, string) (string, error) {
f.calls++
return f.posterPath, f.err
}
type fakeScannerCoverCacher struct {
calls int
data []byte
contentID string
}
func (f *fakeScannerCoverCacher) CacheAudiobookCover(_ context.Context, data []byte, contentID string) (string, string, string, error) {
f.calls++
f.data = append([]byte(nil), data...)
f.contentID = contentID
return "local/audiobooks/" + contentID + "/poster", ".webp", "thumbhash", nil
}
func TestApplyAudiobookEmbeddedCoverStoresPosterDuringScan(t *testing.T) {
dir := t.TempDir()
ffmpegPath := filepath.Join(dir, "ffmpeg")
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nprintf cover-bytes\n"), 0o755); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
exec := &fakeAudiobookPosterExec{}
cacher := &fakeScannerCoverCacher{}
applied, err := applyAudiobookEmbeddedCover(
context.Background(),
&fakeAudiobookPosterReader{},
exec,
ffmpegPath,
cacher,
"/library/Author/Book/book.m4b",
"content-1",
)
if err != nil {
t.Fatalf("applyAudiobookEmbeddedCover: %v", err)
}
if !applied {
t.Fatal("applied = false, want true")
}
if string(cacher.data) != "cover-bytes" || cacher.contentID != "content-1" {
t.Fatalf("cacher got data=%q contentID=%q", string(cacher.data), cacher.contentID)
}
if exec.calls != 1 {
t.Fatalf("Exec calls = %d, want 1", exec.calls)
}
if len(exec.args) != 3 {
t.Fatalf("Exec args = %#v, want 3 args", exec.args)
}
if exec.args[0] != "local/audiobooks/content-1/poster/original.webp" {
t.Fatalf("poster arg = %#v", exec.args[0])
}
if exec.args[1] != "thumbhash" || exec.args[2] != "content-1" {
t.Fatalf("thumb/content args = %#v", exec.args)
}
if !strings.Contains(exec.query, "poster_path = $1") {
t.Fatalf("query did not update poster_path: %s", exec.query)
}
}
func TestApplyAudiobookEmbeddedCoverPreservesExistingPoster(t *testing.T) {
dir := t.TempDir()
ffmpegPath := filepath.Join(dir, "ffmpeg")
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nprintf cover-bytes\n"), 0o755); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
reader := &fakeAudiobookPosterReader{posterPath: "local/audiobooks/content-1/poster/original.webp"}
exec := &fakeAudiobookPosterExec{}
cacher := &fakeScannerCoverCacher{}
applied, err := applyAudiobookEmbeddedCover(
context.Background(),
reader,
exec,
ffmpegPath,
cacher,
"/library/Author/Book/book.m4b",
"content-1",
)
if err != nil {
t.Fatalf("applyAudiobookEmbeddedCover: %v", err)
}
if applied {
t.Fatal("applied = true, want false")
}
if reader.calls != 1 {
t.Fatalf("GetPosterPath calls = %d, want 1", reader.calls)
}
if cacher.calls != 0 {
t.Fatalf("cache calls = %d, want 0", cacher.calls)
}
if exec.calls != 0 {
t.Fatalf("Exec calls = %d, want 0", exec.calls)
}
}
func TestAudiobookDuplicateCandidateSQLFiltersTitleBeforeLimit(t *testing.T) {
titlePredicate := strings.Index(audiobookDuplicateCandidateSQL, "normalized_title = $6")
limit := strings.Index(audiobookDuplicateCandidateSQL, "LIMIT")
if titlePredicate == -1 {
t.Fatalf("expected SQL to filter on normalized_title, got:\n%s", audiobookDuplicateCandidateSQL)
}
if limit == -1 {
t.Fatalf("expected SQL to keep a bounded candidate query, got:\n%s", audiobookDuplicateCandidateSQL)
}
if titlePredicate > limit {
t.Fatalf("title predicate appears after LIMIT, got:\n%s", audiobookDuplicateCandidateSQL)
}
}
func TestPopulateFromTags_SeriesDoesNotFallBackToAlbum(t *testing.T) {
// In audiobook tagging the `album` tag holds the book title, not the
// series name. populateFromTags must NOT use it as a series fallback,
@@ -371,6 +506,43 @@ func TestStripNarratorSuffix(t *testing.T) {
}
}
func TestAudiobookDedupeTitlesMatchPunctuationVariants(t *testing.T) {
if !audiobookDedupeTitlesMatch(
"Witch vs. Witch: F/F Paranormal Romance Novella",
"Witch vs. Witch - F/F Paranormal Romance Novella",
) {
t.Fatal("expected punctuation-only title variants to match")
}
}
func TestAudiobookDedupeTitlesKeepDifferentBooksSeparate(t *testing.T) {
if audiobookDedupeTitlesMatch(
"Adventure of Constance Verity 1 - The Last Adventure of Constance Verity",
"Adventure of Constance Verity 2 - Constance Verity Saves the World",
) {
t.Fatal("expected different series entries to remain separate")
}
}
func TestAudiobookLookupPathsIncludeCaseFoldedVariants(t *testing.T) {
got := audiobookLookupPaths([]parsedAudiobookFile{
{Path: "/Library/A. A. Milne/Winnie-The-Pooh.m4b"},
{Path: ""},
})
want := []string{
"/Library/A. A. Milne/Winnie-The-Pooh.m4b",
"/library/a. a. milne/winnie-the-pooh.m4b",
}
if len(got) != len(want) {
t.Fatalf("paths = %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("paths[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestAudiobookFolderUnchangedAllMatch(t *testing.T) {
now := time.Now().UTC()
files := []*models.MediaFile{
@@ -0,0 +1,8 @@
-- +goose NO TRANSACTION
-- +goose Up
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_media_files_folder_lower_file_path
ON media_files (media_folder_id, lower(file_path));
-- +goose Down
DROP INDEX CONCURRENTLY IF EXISTS idx_media_files_folder_lower_file_path;