fix(metadata): gate episode refresh debt on provenance (#60)

This commit is contained in:
Quick
2026-06-06 22:43:35 -04:00
committed by GitHub
parent 266b4453ca
commit c16d876007
5 changed files with 251 additions and 7 deletions
+2 -2
View File
@@ -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
}
+47 -1
View File
@@ -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
}
@@ -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)
}
})
}
}
+4 -4
View File
@@ -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
+92
View File
@@ -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()