diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index d4dbd372..43358491 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -150,7 +150,7 @@ func (h *ItemsHandler) maybeRequestStaleEpisodeMetadataRefresh(ctx context.Conte if h == nil || h.metadataRefreshRequester == nil || episode == nil { return } - if metadata.EpisodeHasIncompleteMetadata(episode, time.Now()) { + if metadata.EpisodeHasActionableMetadataDebt(episode, time.Now()) { h.requestStaleMetadataRefresh(ctx, "episode", episode.ContentID) } } @@ -161,7 +161,7 @@ func (h *ItemsHandler) maybeRequestStaleSeasonMetadataRefresh(ctx context.Contex } now := time.Now() for _, episode := range episodes { - if metadata.EpisodeHasIncompleteMetadata(episode, now) { + if metadata.EpisodeHasActionableMetadataDebt(episode, now) { h.requestStaleMetadataRefresh(ctx, "season", seasonID) return } diff --git a/internal/metadata/episode_completeness.go b/internal/metadata/episode_completeness.go index 3bd2f3e1..d3aa1a41 100644 --- a/internal/metadata/episode_completeness.go +++ b/internal/metadata/episode_completeness.go @@ -10,7 +10,10 @@ import ( const EpisodeRecentStillWindow = 45 * 24 * time.Hour -var episodePlaceholderTitlePattern = regexp.MustCompile(`(?i)^(tba|tbd|episode\s+\d+)$`) +var ( + episodePlaceholderTitlePattern = regexp.MustCompile(`(?i)^(tba|tbd|episode\s+\d+)$`) + episodeProvisionalTitlePattern = regexp.MustCompile(`(?i)^(tba|tbd)$`) +) func IsEpisodePlaceholderTitle(title string) bool { title = strings.TrimSpace(title) @@ -20,6 +23,23 @@ func IsEpisodePlaceholderTitle(title string) bool { return episodePlaceholderTitlePattern.MatchString(title) } +func isEpisodeProvisionalTitle(title string) bool { + title = strings.TrimSpace(title) + if title == "" { + return false + } + return episodeProvisionalTitlePattern.MatchString(title) +} + +func episodeHasProviderMatch(ep *models.Episode) bool { + if ep == nil || strings.EqualFold(strings.TrimSpace(ep.MetadataSource), "scanner_fallback") { + return false + } + return strings.TrimSpace(ep.TmdbID) != "" || + strings.TrimSpace(ep.TvdbID) != "" || + strings.TrimSpace(ep.ImdbID) != "" +} + func EpisodeHasIncompleteMetadata(ep *models.Episode, now time.Time) bool { if ep == nil { return false @@ -43,3 +63,29 @@ func EpisodeHasIncompleteMetadata(ep *models.Episode, now time.Time) bool { } return false } + +// EpisodeHasActionableMetadataDebt returns true when another provider refresh +// can reasonably be expected to improve the episode metadata. +func EpisodeHasActionableMetadataDebt(ep *models.Episode, now time.Time) bool { + if ep == nil { + return false + } + if strings.TrimSpace(ep.Title) == "" { + return true + } + if strings.EqualFold(strings.TrimSpace(ep.MetadataSource), "scanner_fallback") { + return true + } + if !episodeHasProviderMatch(ep) { + return true + } + if isEpisodeProvisionalTitle(ep.Title) { + return true + } + if ep.AirDate != nil && + !ep.AirDate.Before(now.Add(-EpisodeRecentStillWindow)) && + strings.TrimSpace(ep.StillPath) == "" { + return true + } + return false +} diff --git a/internal/metadata/episode_completeness_test.go b/internal/metadata/episode_completeness_test.go index e9d0e244..ae4d8711 100644 --- a/internal/metadata/episode_completeness_test.go +++ b/internal/metadata/episode_completeness_test.go @@ -46,4 +46,110 @@ func TestEpisodeHasIncompleteMetadata(t *testing.T) { if !EpisodeHasIncompleteMetadata(incomplete, now) { t.Fatal("expected fallback placeholder episode to be treated as incomplete") } + + providerNumbered := &models.Episode{ + Title: "Episode 1", + Overview: "Provider overview", + TmdbID: "3812334", + MetadataSource: "provider", + } + if !EpisodeHasIncompleteMetadata(providerNumbered, now) { + t.Fatal("expected provider numbered title to remain quality-incomplete") + } +} + +func TestEpisodeHasActionableMetadataDebt(t *testing.T) { + now := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC) + recent := now.Add(-7 * 24 * time.Hour) + old := now.Add(-90 * 24 * time.Hour) + + cases := []struct { + name string + ep *models.Episode + want bool + }{ + { + name: "provider numbered title with overview", + ep: &models.Episode{ + Title: "Episode 1", + Overview: "Provider overview", + TmdbID: "3812334", + MetadataSource: "provider", + }, + want: false, + }, + { + name: "provider numbered title with empty overview", + ep: &models.Episode{ + Title: "Episode 1", + TvdbID: "9607609", + MetadataSource: "provider", + }, + want: false, + }, + { + name: "provider provisional title", + ep: &models.Episode{ + Title: "TBD", + TmdbID: "3812334", + MetadataSource: "provider", + }, + want: true, + }, + { + name: "scanner fallback numbered title", + ep: &models.Episode{ + Title: "Episode 1", + MetadataSource: "scanner_fallback", + }, + want: true, + }, + { + name: "missing title", + ep: &models.Episode{ + TmdbID: "3812334", + MetadataSource: "provider", + }, + want: true, + }, + { + name: "missing provider id", + ep: &models.Episode{ + Title: "Pilot", + Overview: "Provider overview", + MetadataSource: "provider", + }, + want: true, + }, + { + name: "recent provider episode missing still", + ep: &models.Episode{ + Title: "Pilot", + Overview: "Provider overview", + TmdbID: "3812334", + AirDate: &recent, + MetadataSource: "provider", + }, + want: true, + }, + { + name: "old provider episode missing still", + ep: &models.Episode{ + Title: "Pilot", + Overview: "Provider overview", + TmdbID: "3812334", + AirDate: &old, + MetadataSource: "provider", + }, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := EpisodeHasActionableMetadataDebt(tc.ep, now); got != tc.want { + t.Fatalf("EpisodeHasActionableMetadataDebt() = %v, want %v", got, tc.want) + } + }) + } } diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 823ade0b..06b43f9f 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -1964,7 +1964,7 @@ func (s *MetadataService) syncRefreshDebtForSeason(ctx context.Context, seasonID reasonMask := int64(0) now := time.Now().UTC() for _, episode := range episodes { - if EpisodeHasIncompleteMetadata(episode, now) { + if EpisodeHasActionableMetadataDebt(episode, now) { reasonMask = RefreshDebtReasonEpisodeIncomplete break } @@ -1999,7 +1999,7 @@ func (s *MetadataService) syncRefreshDebtForEpisode(ctx context.Context, episode } reasonMask := int64(0) now := time.Now().UTC() - if EpisodeHasIncompleteMetadata(episode, now) { + if EpisodeHasActionableMetadataDebt(episode, now) { reasonMask = RefreshDebtReasonEpisodeIncomplete } if reasonMask == 0 { @@ -3515,7 +3515,7 @@ func (s *MetadataService) refreshSeriesEpisodeMetadataState(ctx context.Context, incomplete := false for _, episode := range episodes { - if EpisodeHasIncompleteMetadata(episode, now) { + if EpisodeHasActionableMetadataDebt(episode, now) { incomplete = true } if err := s.syncVisibleEpisodeRefreshDebt(ctx, episode, now); err != nil { @@ -3534,7 +3534,7 @@ func (s *MetadataService) syncVisibleEpisodeRefreshDebt(ctx context.Context, epi if s == nil || s.refreshDebtRepo == nil || episode == nil || strings.TrimSpace(episode.ContentID) == "" { return nil } - if !EpisodeHasIncompleteMetadata(episode, now) { + if !EpisodeHasActionableMetadataDebt(episode, now) { return s.refreshDebtRepo.DeleteTargetDebt(ctx, RefreshTargetEpisode, episode.ContentID) } reasonMask := RefreshDebtReasonEpisodeIncomplete diff --git a/internal/metadata/service_test.go b/internal/metadata/service_test.go index 785af263..71330401 100644 --- a/internal/metadata/service_test.go +++ b/internal/metadata/service_test.go @@ -986,6 +986,98 @@ func TestSyncRefreshDebtForItemPreservesRequestedEpisodeDebt(t *testing.T) { } } +func TestRefreshSeriesEpisodeMetadataStateUsesActionableDebt(t *testing.T) { + now := time.Date(2026, 4, 14, 12, 0, 0, 0, time.UTC) + + t.Run("provider numbered episode clears stale debt", func(t *testing.T) { + h := newTestHarness() + ctx := context.Background() + episodes := newFakeEpisodeRepo() + debts := newFakeRefreshDebtRepo() + h.service.episodeRepo = episodes + h.service.refreshDebtRepo = debts + h.itemRepo.items["series-provider"] = &models.MediaItem{ + ContentID: "series-provider", + Type: "series", + EpisodeMetadataIncomplete: true, + } + if err := episodes.Upsert(ctx, &models.Episode{ + ContentID: "episode-provider", + SeriesID: "series-provider", + SeasonID: "season-provider", + SeasonNumber: 1, + EpisodeNumber: 1, + Title: "Episode 1", + TvdbID: "9607609", + MetadataSource: "provider", + }); err != nil { + t.Fatalf("seed provider episode: %v", err) + } + debts.debts[fakeRefreshDebtKey(RefreshTargetEpisode, "episode-provider")] = &models.MetadataRefreshDebt{ + TargetType: RefreshTargetEpisode, + ContentID: "episode-provider", + ReasonMask: RefreshDebtReasonEpisodeIncomplete, + } + + h.service.refreshSeriesEpisodeMetadataState(ctx, "series-provider", now) + + item, err := h.itemRepo.GetByID(ctx, "series-provider") + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if item.EpisodeMetadataIncomplete { + t.Fatal("expected provider numbered episode to clear series incomplete flag") + } + if item.EpisodeMetadataLastCheckedAt == nil || !item.EpisodeMetadataLastCheckedAt.Equal(now) { + t.Fatalf("last checked at = %v, want %v", item.EpisodeMetadataLastCheckedAt, now) + } + if _, err := debts.GetTarget(ctx, RefreshTargetEpisode, "episode-provider"); !errors.Is(err, ErrRefreshDebtNotFound) { + t.Fatalf("provider episode debt after refresh = %v, want ErrRefreshDebtNotFound", err) + } + }) + + t.Run("scanner fallback episode creates debt", func(t *testing.T) { + h := newTestHarness() + ctx := context.Background() + episodes := newFakeEpisodeRepo() + debts := newFakeRefreshDebtRepo() + h.service.episodeRepo = episodes + h.service.refreshDebtRepo = debts + h.itemRepo.items["series-fallback"] = &models.MediaItem{ + ContentID: "series-fallback", + Type: "series", + } + if err := episodes.Upsert(ctx, &models.Episode{ + ContentID: "episode-fallback", + SeriesID: "series-fallback", + SeasonID: "season-fallback", + SeasonNumber: 1, + EpisodeNumber: 1, + Title: "Episode 1", + MetadataSource: "scanner_fallback", + }); err != nil { + t.Fatalf("seed fallback episode: %v", err) + } + + h.service.refreshSeriesEpisodeMetadataState(ctx, "series-fallback", now) + + item, err := h.itemRepo.GetByID(ctx, "series-fallback") + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if !item.EpisodeMetadataIncomplete { + t.Fatal("expected fallback episode to keep series incomplete flag") + } + debt, err := debts.GetTarget(ctx, RefreshTargetEpisode, "episode-fallback") + if err != nil { + t.Fatalf("GetTarget fallback debt: %v", err) + } + if !hasRefreshDebtReason(debt.ReasonMask, RefreshDebtReasonEpisodeIncomplete) { + t.Fatalf("fallback debt reason mask = %d, want episode incomplete", debt.ReasonMask) + } + }) +} + func TestRequestStaleMetadataRefreshStartsOnDemandRefreshOnce(t *testing.T) { h := newTestHarness() ctx := context.Background()