diff --git a/internal/domain/episodes.go b/internal/domain/episodes.go index 735415ad..0614ba7e 100644 --- a/internal/domain/episodes.go +++ b/internal/domain/episodes.go @@ -28,6 +28,10 @@ type CanonicalEpisodeList struct { Episodes []CanonicalEpisode `json:"episodes"` Source string `json:"source"` NextRefreshAt string `json:"next_refresh_at,omitempty"` + RetryUntilAt string `json:"retry_until_at,omitempty"` + LastAttemptAt string `json:"last_attempt_at,omitempty"` + LastSuccessAt string `json:"last_success_at,omitempty"` + FailureCount int64 `json:"failure_count,omitempty"` } type EpisodeService interface { diff --git a/internal/episodes/service/cache_store.go b/internal/episodes/service/cache_store.go index 96f2442a..91cecb54 100644 --- a/internal/episodes/service/cache_store.go +++ b/internal/episodes/service/cache_store.go @@ -16,9 +16,14 @@ func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpi nextRefreshSQL := nextRefreshAt(anime, now) episodes := mergeEpisodes(jikanEpisodes, availability, anime.Episodes) payload := domain.CanonicalEpisodeList{ - AnimeID: anime.MalID, - Episodes: episodes, - Source: source, + AnimeID: anime.MalID, + Episodes: episodes, + Source: source, + LastAttemptAt: now.Format(time.RFC3339), + FailureCount: 0, + } + if providerSuccess { + payload.LastSuccessAt = now.Format(time.RFC3339) } if nextRefreshSQL.Valid { payload.NextRefreshAt = nextRefreshSQL.Time.Format(time.RFC3339) @@ -146,6 +151,7 @@ func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime) if !ok { return domain.CanonicalEpisodeList{}, false } + payload = enrichCachedPayload(payload, row) observability.Info( "episodes_cache_served", "episodes", @@ -168,9 +174,27 @@ func (s *EpisodeService) getDecodedCached(ctx context.Context, anime domain.Anim if !ok { return domain.CanonicalEpisodeList{}, false } + payload = enrichCachedPayload(payload, row) return payload, true } +func enrichCachedPayload(payload domain.CanonicalEpisodeList, row db.EpisodeAvailabilityCache) domain.CanonicalEpisodeList { + if row.NextRefreshAt.Valid { + payload.NextRefreshAt = row.NextRefreshAt.Time.Format(time.RFC3339) + } + if row.RetryUntilAt.Valid { + payload.RetryUntilAt = row.RetryUntilAt.Time.Format(time.RFC3339) + } + if row.LastAttemptAt.Valid { + payload.LastAttemptAt = row.LastAttemptAt.Time.Format(time.RFC3339) + } + if row.LastSuccessAt.Valid { + payload.LastSuccessAt = row.LastSuccessAt.Time.Format(time.RFC3339) + } + payload.FailureCount = row.FailureCount + return payload +} + func (s *EpisodeService) isFreshEpisodeCache(anime domain.Anime, row db.EpisodeAvailabilityCache, now time.Time) bool { if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) { observability.Info( diff --git a/internal/episodes/service/service_test.go b/internal/episodes/service/service_test.go index 5fd200c2..77394a7f 100644 --- a/internal/episodes/service/service_test.go +++ b/internal/episodes/service/service_test.go @@ -1,7 +1,9 @@ package service import ( + "database/sql" "mal/integrations/jikan" + "mal/internal/db" "mal/internal/domain" "testing" "time" @@ -127,6 +129,37 @@ func TestIsCanonicalEpisodePayloadValidAllowsJikanFallbackWithoutAvailability(t } } +func TestEnrichCachedPayloadAddsRefreshMetadata(t *testing.T) { + now := time.Date(2026, time.June, 27, 11, 0, 0, 0, time.UTC) + payload := enrichCachedPayload(domain.CanonicalEpisodeList{ + AnimeID: 59970, + Episodes: []domain.CanonicalEpisode{{Number: 1}}, + Source: "AllAnime", + }, db.EpisodeAvailabilityCache{ + NextRefreshAt: sql.NullTime{Time: now.Add(time.Hour), Valid: true}, + RetryUntilAt: sql.NullTime{Time: now.Add(30 * time.Minute), Valid: true}, + LastAttemptAt: sql.NullTime{Time: now.Add(-5 * time.Minute), Valid: true}, + LastSuccessAt: sql.NullTime{Time: now.Add(-time.Hour), Valid: true}, + FailureCount: 2, + }) + + if payload.NextRefreshAt != "2026-06-27T12:00:00Z" { + t.Fatalf("NextRefreshAt = %q, want RFC3339 timestamp", payload.NextRefreshAt) + } + if payload.RetryUntilAt != "2026-06-27T11:30:00Z" { + t.Fatalf("RetryUntilAt = %q, want RFC3339 timestamp", payload.RetryUntilAt) + } + if payload.LastAttemptAt != "2026-06-27T10:55:00Z" { + t.Fatalf("LastAttemptAt = %q, want RFC3339 timestamp", payload.LastAttemptAt) + } + if payload.LastSuccessAt != "2026-06-27T10:00:00Z" { + t.Fatalf("LastSuccessAt = %q, want RFC3339 timestamp", payload.LastSuccessAt) + } + if payload.FailureCount != 2 { + t.Fatalf("FailureCount = %d, want 2", payload.FailureCount) + } +} + func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) { anime := domain.Anime{Anime: jikan.Anime{MalID: 1}} anime.Broadcast.Day = "Saturdays"