feat: reject stale caches and filter future episodes

This commit is contained in:
2026-07-01 18:53:31 +02:00
parent 0bf78f1ea3
commit d61ed31d1e
4 changed files with 243 additions and 13 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"strings"
"time"
"mal/integrations/jikan"
@@ -12,15 +13,19 @@ import (
"mal/internal/observability"
)
const episodeAvailabilityPayloadVersion = 2
func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, source string, now time.Time, providerSuccess bool) (domain.CanonicalEpisodeList, error) {
nextRefreshSQL := nextRefreshAt(anime, now)
episodes := mergeEpisodes(jikanEpisodes, availability, anime.Episodes)
episodes := mergeEpisodesForAnime(anime, jikanEpisodes, availability, now, providerSuccess)
payload := domain.CanonicalEpisodeList{
AnimeID: anime.MalID,
Episodes: episodes,
Source: source,
LastAttemptAt: now.Format(time.RFC3339),
FailureCount: 0,
AnimeID: anime.MalID,
Episodes: episodes,
Source: source,
AvailabilityVersion: episodeAvailabilityPayloadVersion,
ReleaseChecked: !providerSuccess,
LastAttemptAt: now.Format(time.RFC3339),
FailureCount: 0,
}
if providerSuccess {
payload.LastSuccessAt = now.Format(time.RFC3339)
@@ -238,6 +243,35 @@ func (s *EpisodeService) decodeCachedPayload(anime domain.Anime, raw string) (do
return domain.CanonicalEpisodeList{}, false
}
if isStaleAiringEpisodePayload(anime, payload) {
observability.Info(
"episodes_cached_payload_rejected_stale_version",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"cached_episodes": len(payload.Episodes),
"source": payload.Source,
"availability_version": payload.AvailabilityVersion,
},
)
return domain.CanonicalEpisodeList{}, false
}
if isUncheckedAiringJikanFallback(anime, payload) {
observability.Info(
"episodes_cached_payload_rejected_unchecked_fallback",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"cached_episodes": len(payload.Episodes),
"source": payload.Source,
},
)
return domain.CanonicalEpisodeList{}, false
}
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
observability.Info(
"episodes_cached_payload_rejected",
@@ -254,3 +288,18 @@ func (s *EpisodeService) decodeCachedPayload(anime domain.Anime, raw string) (do
return payload, true
}
func isStaleAiringEpisodePayload(anime domain.Anime, payload domain.CanonicalEpisodeList) bool {
return isAiring(anime) && len(payload.Episodes) > 0 && payload.AvailabilityVersion < episodeAvailabilityPayloadVersion
}
func isUncheckedAiringJikanFallback(anime domain.Anime, payload domain.CanonicalEpisodeList) bool {
if !isAiring(anime) || payload.ReleaseChecked || len(payload.Episodes) == 0 {
return false
}
return payload.Source == "jikan_fallback" || payload.Source == "legacy_disabled"
}
func isAiring(anime domain.Anime) bool {
return anime.Airing || strings.EqualFold(strings.TrimSpace(anime.Status), "Currently Airing")
}