fix(metadata): gate episode refresh debt on provenance (#60)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user