diff --git a/internal/scanner/audiobook.go b/internal/scanner/audiobook.go index 2a59cf09..121aeb73 100644 --- a/internal/scanner/audiobook.go +++ b/internal/scanner/audiobook.go @@ -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. diff --git a/internal/scanner/audiobook_scan.go b/internal/scanner/audiobook_scan.go index 68fe66e0..703102fe 100644 --- a/internal/scanner/audiobook_scan.go +++ b/internal/scanner/audiobook_scan.go @@ -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 diff --git a/internal/scanner/audiobook_test.go b/internal/scanner/audiobook_test.go index a438e6c1..152d4bec 100644 --- a/internal/scanner/audiobook_test.go +++ b/internal/scanner/audiobook_test.go @@ -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{ diff --git a/migrations/sql/20260609120500_media_files_lower_file_path_index.sql b/migrations/sql/20260609120500_media_files_lower_file_path_index.sql new file mode 100644 index 00000000..f3e0568b --- /dev/null +++ b/migrations/sql/20260609120500_media_files_lower_file_path_index.sql @@ -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;