diff --git a/internal/notifications/release_repo.go b/internal/notifications/release_repo.go index aed82f1b..99f81b53 100644 --- a/internal/notifications/release_repo.go +++ b/internal/notifications/release_repo.go @@ -75,7 +75,7 @@ func (r *ReleaseRepository) RecordAvailabilityForLibrary(ctx context.Context, li JOIN episodes e ON e.content_id = el.episode_id WHERE el.media_folder_id = $1 AND ` + availabilityOrdinalGuard + ` - ON CONFLICT (library_id, episode_id) DO NOTHING` + availabilityReturning + ON CONFLICT DO NOTHING` + availabilityReturning return r.recordAvailability(ctx, libraryID, emitEvents, query, []any{libraryID}) } @@ -106,7 +106,7 @@ func (r *ReleaseRepository) RecordAvailabilityForPaths(ctx context.Context, libr AND mf.episode_id IS NOT NULL AND ` + availabilityOrdinalGuard + ` AND (` + strings.Join(scopeConds, " OR ") + `) - ON CONFLICT (library_id, episode_id) DO NOTHING` + availabilityReturning + ON CONFLICT DO NOTHING` + availabilityReturning return r.recordAvailability(ctx, libraryID, emitEvents, query, args) } @@ -262,9 +262,16 @@ func (r *ReleaseRepository) recordMovieAvailability(ctx context.Context, library return len(inserted), events, nil } +// EpisodeDedupeKey composes the release_events dedupe key for an episode. +// It keys the logical episode in a library, not the catalog row id, so a +// series re-ID or episode-row re-mint that remaps the episode id does not +// become a new release. +func EpisodeDedupeKey(libraryID int, seriesID string, episodeKey int) string { + return fmt.Sprintf("episode:%d:%s:%d", libraryID, seriesID, episodeKey) +} + // MovieDedupeKey composes the release_events dedupe key for a movie. The -// "movie:" prefix keeps the keyspace disjoint from episode keys -// ("{library_id}:{episode_id}"). +// "movie:" prefix keeps the keyspace disjoint from episode keys. func MovieDedupeKey(libraryID int, itemID string) string { return fmt.Sprintf("movie:%d:%s", libraryID, itemID) } @@ -349,7 +356,7 @@ func insertReleaseEvents(ctx context.Context, tx pgx.Tx, libraryID int, rows []n row.EpisodeNumber, row.EpisodeKey, row.AvailableAt, - fmt.Sprintf("%d:%s", libraryID, row.EpisodeID), + EpisodeDedupeKey(libraryID, row.SeriesID, row.EpisodeKey), ) } sb.WriteString(" ON CONFLICT (dedupe_key) DO NOTHING") diff --git a/internal/notifications/release_types.go b/internal/notifications/release_types.go index 8d317f33..7a2355e1 100644 --- a/internal/notifications/release_types.go +++ b/internal/notifications/release_types.go @@ -41,8 +41,10 @@ func normalizeEventKind(kind string) string { } // ReleaseEvent is one logical "content became newly available in a library" -// event. dedupe_key is "{library_id}:{episode_id}" for episodes and -// "movie:{library_id}:{item_id}" for movies. +// event. New episode events use dedupe_key +// "episode:{library_id}:{series_id}:{episode_key}" so episode-id churn from a +// series re-ID or episode-row re-mint does not create another release. Movie +// events use "movie:{library_id}:{item_id}". type ReleaseEvent struct { ID string LibraryID int diff --git a/internal/notifications/server_channel_logic_test.go b/internal/notifications/server_channel_logic_test.go index 1de181ee..ed7af60b 100644 --- a/internal/notifications/server_channel_logic_test.go +++ b/internal/notifications/server_channel_logic_test.go @@ -436,11 +436,13 @@ func TestServerChannelHeadersSigned(t *testing.T) { } } -func TestMovieDedupeKeyDisjointFromEpisodeKeys(t *testing.T) { - // Episode dedupe keys are "{library_id}:{episode_id}". A movie key must - // never collide even if a movie item id equals an episode id. - if MovieDedupeKey(3, "abc") == "3:abc" { - t.Fatal("movie dedupe keys must live in their own keyspace") +func TestReleaseDedupeKeysAreDisjoint(t *testing.T) { + episodeKey := EpisodeDedupeKey(3, "series-abc", EpisodeKey(2, 4)) + if episodeKey != "episode:3:series-abc:2000004" { + t.Fatalf("unexpected episode dedupe key %q", episodeKey) + } + if MovieDedupeKey(3, "series-abc:2000004") == episodeKey { + t.Fatal("movie and episode dedupe keys must live in separate keyspaces") } if got := MovieDedupeKey(3, "abc"); got != "movie:3:abc" { t.Fatalf("unexpected movie dedupe key %q", got) diff --git a/internal/scanner/audiobook.go b/internal/scanner/audiobook.go index 121aeb73..ade06e8a 100644 --- a/internal/scanner/audiobook.go +++ b/internal/scanner/audiobook.go @@ -34,6 +34,9 @@ var unabridgedTokenRE = regexp.MustCompile(`(?i)\s*\(unabridged\)\s*`) var collapseSpacesRE = regexp.MustCompile(`\s+`) var audiobookDedupeTitleTokenRE = regexp.MustCompile(`[^A-Za-z0-9]+`) +var audiobookFilesystemTitleNumericIDRE = regexp.MustCompile(`\s*[\[(]\d+[\])]\s*$`) +var audiobookFilesystemTitleNoiseSuffixRE = regexp.MustCompile(`(?i)(?:\s*[-_. ]+\s*)?(?:nmr|audio\s*book|audiobook|unabridged|abridged)\s*$`) + // stripNarratorSuffix removes the narrator-suffix noise and "(unabridged)" // markers from a title. Returns the input unchanged when no match. // Kept in sync with the SQL `regexp_replace` used by migration 146 so @@ -136,6 +139,7 @@ func parseAudiobookFolder(ctx context.Context, ffprobePath string, folderPath st return nil, fmt.Errorf("probe audiobook file %s: %w", audioFiles[0], err) } book.populateFromTags(probed.FormatTags) + book.applyFilesystemFallbacks(folderPath, audioFiles) book.Files = []parsedAudiobookFile{{ Path: audioFiles[0], Chapters: probed.Chapters, @@ -156,6 +160,7 @@ func parseAudiobookFolder(ctx context.Context, ffprobePath string, folderPath st return nil, fmt.Errorf("probe first audiobook file %s: %w", audioFiles[0], err) } book.populateFromTags(probedFirst.FormatTags) + book.applyFilesystemFallbacks(folderPath, audioFiles) book.Files = make([]parsedAudiobookFile, 0, len(audioFiles)) for i, path := range audioFiles { @@ -244,6 +249,42 @@ func parseGenresFromTags(tags map[string]string) []string { return out } +func (b *parsedAudiobook) applyFilesystemFallbacks(folderPath string, audioFiles []string) { + if b == nil || strings.TrimSpace(b.Title) != "" { + return + } + b.Title = deriveAudiobookTitleFromFilesystem(folderPath, audioFiles) +} + +func deriveAudiobookTitleFromFilesystem(folderPath string, audioFiles []string) string { + candidates := []string{filepath.Base(folderPath)} + if len(audioFiles) == 1 { + file := audioFiles[0] + candidates = append(candidates, strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) + } + for _, candidate := range candidates { + if cleaned := cleanAudiobookFilesystemTitle(candidate); cleaned != "" { + return cleaned + } + } + return "" +} + +func cleanAudiobookFilesystemTitle(title string) string { + cleaned := strings.TrimSpace(title) + cleaned = audiobookFilesystemTitleNumericIDRE.ReplaceAllString(cleaned, "") + cleaned = strings.NewReplacer(".", " ", "_", " ").Replace(cleaned) + for { + next := audiobookFilesystemTitleNoiseSuffixRE.ReplaceAllString(cleaned, "") + if next == cleaned { + break + } + cleaned = next + } + cleaned = collapseSpacesRE.ReplaceAllString(cleaned, " ") + return strings.Trim(cleaned, " -_.") +} + // parseTagYear extracts a 4-digit year (e.g. 1900-9999) from a tag value // that may be a bare year ("2024"), an ISO date ("2024-05-23"), or a // padded form ("(2024)"). Returns 0 if no plausible year is found. diff --git a/internal/scanner/audiobook_scan.go b/internal/scanner/audiobook_scan.go index 30743740..996e07af 100644 --- a/internal/scanner/audiobook_scan.go +++ b/internal/scanner/audiobook_scan.go @@ -153,11 +153,17 @@ func (s *Scanner) audiobookFolderShouldSkip(ctx context.Context, folder *models. if contentID == "" { return "", false, nil } - statuses, err := s.itemRepo.GetStatusByIDs(ctx, []string{contentID}) + items, err := s.itemRepo.GetByIDs(ctx, []string{contentID}) if err != nil { - return "", false, fmt.Errorf("get item status: %w", err) + return "", false, fmt.Errorf("get item for skip check: %w", err) } - if strings.EqualFold(strings.TrimSpace(statuses[contentID]), "unmatched") { + if len(items) == 0 || items[0] == nil { + return "", false, nil + } + if strings.TrimSpace(items[0].Title) == "" { + return "", false, nil + } + if strings.EqualFold(strings.TrimSpace(items[0].Status), "unmatched") { return "", false, nil } return contentID, true, nil @@ -652,6 +658,9 @@ func (s *Scanner) upsertAudiobookMediaItem(ctx context.Context, folderID int, fo return "", fmt.Errorf("find audiobook by root path: %w", err) } if existingID != "" { + if err := s.updateExistingAudiobookMediaItem(ctx, existingID, book); err != nil { + return "", err + } return existingID, nil } @@ -691,6 +700,22 @@ func (s *Scanner) upsertAudiobookMediaItem(ctx context.Context, folderID int, fo return createAudiobookMediaItem(ctx, s.itemRepo, book, cleanTitle) } +func (s *Scanner) updateExistingAudiobookMediaItem(ctx context.Context, contentID string, book *parsedAudiobook) error { + items, err := s.itemRepo.GetByIDs(ctx, []string{contentID}) + if err != nil { + return fmt.Errorf("get audiobook media item %s: %w", contentID, err) + } + if len(items) == 0 || items[0] == nil { + return fmt.Errorf("audiobook media item %s not found", contentID) + } + item := items[0] + applyBookToMediaItem(item, book) + if item.SortTitle == "" { + item.SortTitle = titleutil.DeriveDefaultSortTitle(item.Title) + } + return s.itemRepo.Upsert(ctx, item) +} + func resolveAudiobookMediaItem( ctx context.Context, rootFinder filesystemRootContentFinder, @@ -870,8 +895,11 @@ func audiobookLookupPaths(files []parsedAudiobookFile) []string { // in OriginalTitle when it differs so the original is never lost. func applyBookToMediaItem(item *models.MediaItem, book *parsedAudiobook) { item.Type = "audiobook" - raw := book.Title + raw := strings.TrimSpace(book.Title) cleaned := stripNarratorSuffix(raw) + if cleaned == "" { + cleaned = raw + } item.Title = cleaned if cleaned != raw && item.OriginalTitle == "" { item.OriginalTitle = raw diff --git a/internal/scanner/audiobook_test.go b/internal/scanner/audiobook_test.go index ac920726..083c307f 100644 --- a/internal/scanner/audiobook_test.go +++ b/internal/scanner/audiobook_test.go @@ -289,6 +289,36 @@ func TestParseAudiobookFolderMultiFile(t *testing.T) { } } +func TestApplyAudiobookFilesystemFallbacksUsesFolderNameWhenTagsAreBlank(t *testing.T) { + book := &parsedAudiobook{} + book.applyFilesystemFallbacks( + "/library/Calibre_Audio_Library/Dean Koontz/Devoted (2799)", + []string{"/library/Calibre_Audio_Library/Dean Koontz/Devoted (2799)/Devoted - Dean Koontz.mp3"}, + ) + if book.Title != "Devoted" { + t.Fatalf("Title = %q, want Devoted", book.Title) + } +} + +func TestApplyAudiobookFilesystemFallbacksPreservesTaggedTitle(t *testing.T) { + book := &parsedAudiobook{Title: "Tagged Title"} + book.applyFilesystemFallbacks( + "/library/Bad.Folder.Name-AudioBook", + []string{"/library/Bad.Folder.Name-AudioBook/part01.mp3"}, + ) + if book.Title != "Tagged Title" { + t.Fatalf("Title = %q, want tagged title", book.Title) + } +} + +func TestCleanAudiobookFilesystemTitleRemovesCommonReleaseNoise(t *testing.T) { + got := cleanAudiobookFilesystemTitle("Dan.Brown-Robert.Langdon.Bk.2-The.DaVinci.Code.NMR-AudioBook") + want := "Dan Brown-Robert Langdon Bk 2-The DaVinci Code" + if got != want { + t.Fatalf("cleanAudiobookFilesystemTitle = %q, want %q", got, want) + } +} + func TestAudiobookIdentityConfidenceReflectsMetadataCompleteness(t *testing.T) { book := &parsedAudiobook{Title: "Tagged Book", Author: "Author", Narrator: "Narrator", Year: 2024} file := parsedAudiobookFile{Chapters: []ChapterInfo{{Title: "One", StartSeconds: 0, EndSeconds: 10}}} @@ -582,6 +612,14 @@ func TestAudiobookLookupPathsIncludeCaseFoldedVariants(t *testing.T) { } } +func TestApplyBookToMediaItemDoesNotBlankAllNarratorSuffixTitle(t *testing.T) { + item := &models.MediaItem{} + applyBookToMediaItem(item, &parsedAudiobook{Title: "Read by Jim Dale"}) + if item.Title != "Read by Jim Dale" { + t.Fatalf("Title = %q, want raw title fallback", item.Title) + } +} + func TestAudiobookFolderUnchangedAllMatch(t *testing.T) { now := time.Now().UTC() files := []*models.MediaFile{ diff --git a/migrations/sql/20260615230358_dedupe_episode_availability_logically.sql b/migrations/sql/20260615230358_dedupe_episode_availability_logically.sql new file mode 100644 index 00000000..e41de323 --- /dev/null +++ b/migrations/sql/20260615230358_dedupe_episode_availability_logically.sql @@ -0,0 +1,36 @@ +-- +goose Up +-- episode_availability records "this logical episode has already been +-- available in this library." The original primary key only covered the +-- catalog episode_id, so a series re-ID or episode-row re-mint (e.g. a local +-- series gaining a provider match) could remap that id and make an +-- already-present episode look newly available. Collapse any existing +-- duplicates and enforce the logical identity going forward. +WITH ranked AS ( + SELECT + library_id, + episode_id, + row_number() OVER ( + PARTITION BY library_id, series_id, episode_key + ORDER BY available_at ASC, created_at ASC, episode_id ASC + ) AS rn + FROM public.episode_availability +) +DELETE FROM public.episode_availability ea +USING ranked r +WHERE ea.library_id = r.library_id + AND ea.episode_id = r.episode_id + AND r.rn > 1; + +CREATE UNIQUE INDEX episode_availability_logical_episode_key + ON public.episode_availability (library_id, series_id, episode_key); + +-- The non-unique (library_id, series_id, episode_key DESC) index is now +-- redundant: the unique index above covers the same leading columns (Postgres +-- scans it backwards for DESC reads) and the table is insert-only, so nothing +-- reads it. Drop it rather than carry two indexes over the same tuple. +DROP INDEX IF EXISTS public.episode_availability_series_idx; + +-- +goose Down +CREATE INDEX episode_availability_series_idx + ON public.episode_availability (library_id, series_id, episode_key DESC); +DROP INDEX IF EXISTS public.episode_availability_logical_episode_key;