fix: use provider availability for episodes

This commit is contained in:
2026-06-21 16:49:30 +02:00
committed by Milas Holsting
parent cf641ce79b
commit b0bebec656
4 changed files with 99 additions and 11 deletions

View File

@@ -131,8 +131,8 @@ func (s *EpisodeService) markFailure(ctx context.Context, anime domain.Anime, ca
) )
} }
func (s *EpisodeService) getCached(ctx context.Context, animeID int) (domain.CanonicalEpisodeList, bool) { func (s *EpisodeService) getCached(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, bool) {
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(animeID)) row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
if err != nil { if err != nil {
s.metrics.ObserveCache("episode_availability", "miss") s.metrics.ObserveCache("episode_availability", "miss")
return domain.CanonicalEpisodeList{}, false return domain.CanonicalEpisodeList{}, false
@@ -145,12 +145,26 @@ func (s *EpisodeService) getCached(ctx context.Context, animeID int) (domain.Can
"episodes", "episodes",
"", "",
map[string]any{ map[string]any{
"anime_id": animeID, "anime_id": anime.MalID,
}, },
err, err,
) )
return domain.CanonicalEpisodeList{}, false return domain.CanonicalEpisodeList{}, false
} }
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
s.metrics.ObserveCache("episode_availability", "miss")
observability.Info(
"episodes_cached_payload_rejected",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"expected_count": anime.Episodes,
"cached_episodes": len(payload.Episodes),
},
)
return domain.CanonicalEpisodeList{}, false
}
s.metrics.ObserveCache("episode_availability", "hit") s.metrics.ObserveCache("episode_availability", "hit")
return payload, true return payload, true
} }

View File

@@ -36,7 +36,7 @@ func titleCandidates(anime domain.Anime) []string {
func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expectedCount int) bool { func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expectedCount int) bool {
if expectedCount <= 0 { if expectedCount <= 0 {
return true return providerBackedPayloadHasAvailability(payload)
} }
if len(payload.Episodes) > expectedCount { if len(payload.Episodes) > expectedCount {
return false return false
@@ -46,18 +46,36 @@ func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expecte
return false return false
} }
} }
return providerBackedPayloadHasAvailability(payload)
}
func providerBackedPayloadHasAvailability(payload domain.CanonicalEpisodeList) bool {
if payload.Source == "" || payload.Source == "jikan_fallback" || payload.Source == "legacy_disabled" {
return true
}
for _, episode := range payload.Episodes {
if !episode.HasSub && !episode.HasDub {
return false
}
}
return true return true
} }
func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int) []domain.CanonicalEpisode { func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int) []domain.CanonicalEpisode {
byNumber := map[int]episodePartial{} byNumber := map[int]episodePartial{}
providerNumbers := availableEpisodeNumbers(availability, expectedCount)
providerBacked := len(providerNumbers) > 0
for number := range providerNumbers {
mergeEpisode(&byNumber, number, func(item *episodePartial) {})
}
for i, ep := range jikanEpisodes { for i, ep := range jikanEpisodes {
if exceedsExpectedCount(i+1, expectedCount) { if exceedsExpectedCount(i+1, expectedCount) {
break break
} }
number, ok := jikanEpisodeNumber(ep, i) number, ok := jikanEpisodeNumber(ep, i)
if !ok || exceedsExpectedCount(number, expectedCount) { if !ok || exceedsExpectedCount(number, expectedCount) || (providerBacked && !providerNumbers[number]) {
continue continue
} }
mergeEpisode(&byNumber, number, func(item *episodePartial) { mergeEpisode(&byNumber, number, func(item *episodePartial) {
@@ -95,6 +113,21 @@ func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAva
return episodes return episodes
} }
func availableEpisodeNumbers(availability domain.EpisodeAvailability, expectedCount int) map[int]bool {
numbers := map[int]bool{}
for _, number := range availability.Sub {
if number > 0 && !exceedsExpectedCount(number, expectedCount) {
numbers[number] = true
}
}
for _, number := range availability.Dub {
if number > 0 && !exceedsExpectedCount(number, expectedCount) {
numbers[number] = true
}
}
return numbers
}
func mergeEpisode(byNumber *map[int]episodePartial, number int, update func(*episodePartial)) { func mergeEpisode(byNumber *map[int]episodePartial, number int, update func(*episodePartial)) {
item := (*byNumber)[number] item := (*byNumber)[number]
update(&item) update(&item)

View File

@@ -143,7 +143,7 @@ func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domai
providerAvailability, source, providerErr := s.fetchProviderAvailability(ctx, anime) providerAvailability, source, providerErr := s.fetchProviderAvailability(ctx, anime)
if providerErr != nil { if providerErr != nil {
s.markFailure(ctx, anime, providerErr) s.markFailure(ctx, anime, providerErr)
if cached, ok := s.getCached(ctx, anime.MalID); ok { if cached, ok := s.getCached(ctx, anime); ok {
observability.Warn( observability.Warn(
"episodes_provider_failed_serving_stale_cache", "episodes_provider_failed_serving_stale_cache",
"episodes", "episodes",

View File

@@ -7,7 +7,7 @@ import (
"time" "time"
) )
func TestMergeEpisodesUsesUnionAndSynthesizesProviderOnlyEntries(t *testing.T) { func TestMergeEpisodesUsesProviderAvailabilityAsSourceOfTruth(t *testing.T) {
episodes := mergeEpisodes([]jikan.Episode{ episodes := mergeEpisodes([]jikan.Episode{
{MalID: 101, Episode: "1", Title: "Start"}, {MalID: 101, Episode: "1", Title: "Start"},
{MalID: 102, Episode: "2", Title: "Second", Filler: true}, {MalID: 102, Episode: "2", Title: "Second", Filler: true},
@@ -17,15 +17,28 @@ func TestMergeEpisodesUsesUnionAndSynthesizesProviderOnlyEntries(t *testing.T) {
Dub: []int{1, 2, 3}, Dub: []int{1, 2, 3},
}, 0) }, 0)
if len(episodes) != 5 { if len(episodes) != 4 {
t.Fatalf("len(episodes) = %d, want 5", len(episodes)) t.Fatalf("len(episodes) = %d, want 4", len(episodes))
} }
assertEpisode(t, episodes[0], 1, "Start", true, true, false, false, false) assertEpisode(t, episodes[0], 1, "Start", true, true, false, false, false)
assertEpisode(t, episodes[1], 2, "Second", true, true, false, true, false) assertEpisode(t, episodes[1], 2, "Second", true, true, false, true, false)
assertEpisode(t, episodes[2], 3, "Episode 3", true, true, false, false, false) assertEpisode(t, episodes[2], 3, "Episode 3", true, true, false, false, false)
assertEpisode(t, episodes[3], 5, "Future", false, false, false, false, true) assertEpisode(t, episodes[3], 6, "Episode 6", true, false, true, false, false)
assertEpisode(t, episodes[4], 6, "Episode 6", true, false, true, false, false) }
func TestMergeEpisodesUsesJikanWhenProviderAvailabilityMissing(t *testing.T) {
episodes := mergeEpisodes([]jikan.Episode{
{MalID: 101, Episode: "1", Title: "Start"},
{MalID: 102, Episode: "2", Title: "Second"},
}, domain.EpisodeAvailability{}, 0)
if len(episodes) != 2 {
t.Fatalf("len(episodes) = %d, want 2", len(episodes))
}
assertEpisode(t, episodes[0], 1, "Start", false, false, false, false, false)
assertEpisode(t, episodes[1], 2, "Second", false, false, false, false, false)
} }
func TestMergeEpisodesIgnoresInvalidJikanEpisodeNumbers(t *testing.T) { func TestMergeEpisodesIgnoresInvalidJikanEpisodeNumbers(t *testing.T) {
@@ -86,6 +99,34 @@ func TestIsCanonicalEpisodePayloadValidRejectsOverflowingCachedPayload(t *testin
} }
} }
func TestIsCanonicalEpisodePayloadValidRejectsProviderEpisodesWithoutAvailability(t *testing.T) {
payload := domain.CanonicalEpisodeList{
Source: "AllAnime",
Episodes: []domain.CanonicalEpisode{
{Number: 1, Title: "Episode 1", HasSub: true},
{Number: 2, Title: "Episode 2"},
},
}
if isCanonicalEpisodePayloadValid(payload, 13) {
t.Fatal("expected cached payload to be rejected")
}
}
func TestIsCanonicalEpisodePayloadValidAllowsJikanFallbackWithoutAvailability(t *testing.T) {
payload := domain.CanonicalEpisodeList{
Source: "jikan_fallback",
Episodes: []domain.CanonicalEpisode{
{Number: 1, Title: "Episode 1"},
{Number: 2, Title: "Episode 2"},
},
}
if !isCanonicalEpisodePayloadValid(payload, 13) {
t.Fatal("expected cached payload to be valid")
}
}
func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) { func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}} anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
anime.Broadcast.Day = "Saturdays" anime.Broadcast.Day = "Saturdays"