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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user