Fix Jellyfin parent browsing and release dedupe

- List seasons or episodes correctly for Jellyfin parent item queries
- Deduplicate episode availability by logical episode identity
- Improve audiobook title fallbacks and existing item updates
This commit is contained in:
Quick
2026-06-17 13:12:18 -04:00
parent e99079abf8
commit 20e0dd7fe2
7 changed files with 170 additions and 16 deletions
+12 -5
View File
@@ -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")
+4 -2
View File
@@ -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
@@ -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)
+41
View File
@@ -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.
+32 -4
View File
@@ -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
+38
View File
@@ -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{
@@ -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;