From 45e69dd38d62c791f027f5a097d33c462881b9cf Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sun, 21 Jun 2026 16:49:38 +0200 Subject: [PATCH] fix: source anime episode counts from availability --- internal/anime/details_handler.go | 44 ++++++++++++++++++++--- internal/anime/handler_test.go | 58 ++++++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/internal/anime/details_handler.go b/internal/anime/details_handler.go index 23881c7..7b01f04 100644 --- a/internal/anime/details_handler.go +++ b/internal/anime/details_handler.go @@ -22,6 +22,11 @@ const ( episodeCountTimeout = 4 * time.Second ) +type animeEpisodeCountDisplay struct { + Count int + Label string +} + func listedEpisodeCount(episodes []domain.EpisodeData) int { count := 0 for _, episode := range episodes { @@ -50,7 +55,29 @@ func releasedEpisodeCount(anime domain.Anime, now time.Time) int { return count } -func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime, now time.Time) int { +func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime, now time.Time) animeEpisodeCountDisplay { + if h.episodeSvc != nil { + episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout) + defer cancel() + + episodeList, err := h.episodeSvc.GetCanonicalEpisodes(episodeCtx, anime, false) + if err == nil { + if count := len(episodeList.Episodes); count > 0 { + return animeEpisodeCountDisplay{Count: count, Label: "Available episodes"} + } + } else { + observability.Warn( + "anime_episode_availability_count_fetch_failed", + "anime", + "", + map[string]any{ + "anime_id": anime.MalID, + }, + err, + ) + } + } + if h.svc != nil && anime.Airing { episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout) defer cancel() @@ -58,7 +85,7 @@ func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime episodes, err := h.svc.GetAllEpisodes(episodeCtx, anime.MalID) if err == nil { if count := listedEpisodeCount(episodes); count > 0 { - return count + return animeEpisodeCountDisplay{Count: count, Label: "Listed episodes"} } } else { observability.Warn( @@ -73,7 +100,13 @@ func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime } } - return releasedEpisodeCount(anime, now) + if anime.Episodes > 0 { + return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"} + } + if count := releasedEpisodeCount(anime, now); count > 0 { + return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"} + } + return animeEpisodeCountDisplay{} } func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string { @@ -160,7 +193,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { } } - episodesCount := releasedEpisodeCount(anime, time.Now()) + episodesCount := h.animeEpisodeCount(c.Request.Context(), anime, time.Now()) c.HTML(http.StatusOK, "anime.gohtml", gin.H{ "Anime": anime, @@ -170,7 +203,8 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { "WatchlistIDs": watchlistIDs, "ContinueWatchingEp": ep, "ContinueWatchingTime": cwSeconds, - "EpisodesCount": episodesCount, + "EpisodesCount": episodesCount.Count, + "EpisodesCountLabel": episodesCount.Label, }) } diff --git a/internal/anime/handler_test.go b/internal/anime/handler_test.go index dcb708e..e70ea85 100644 --- a/internal/anime/handler_test.go +++ b/internal/anime/handler_test.go @@ -10,13 +10,15 @@ import ( ) type stubEpisodeService struct { - episodes domain.CanonicalEpisodeList - err error - forced bool + episodes domain.CanonicalEpisodeList + err error + called int + forceRefresh bool } func (s *stubEpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) { - s.forced = forceRefresh + s.called++ + s.forceRefresh = forceRefresh if s.err != nil { return domain.CanonicalEpisodeList{}, s.err } @@ -127,6 +129,52 @@ func TestListedEpisodeCount(t *testing.T) { } } +func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) { + episodeSvc := &stubEpisodeService{ + episodes: domain.CanonicalEpisodeList{ + Source: "AllAnime", + Episodes: []domain.CanonicalEpisode{ + {Number: 1}, + {Number: 2}, + {Number: 3}, + }, + }, + } + handler := NewAnimeHandler(nil, nil, episodeSvc) + + got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{ + MalID: 59970, + Airing: true, + Episodes: 12, + Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"}, + }}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC)) + + if got.Count != 3 || got.Label != "Available episodes" { + t.Fatalf("animeEpisodeCount() = %+v, want count=3 label=%q", got, "Available episodes") + } + if episodeSvc.called != 1 { + t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called) + } + if episodeSvc.forceRefresh { + t.Fatal("animeEpisodeCount() should use fresh cache when available") + } +} + +func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) { + episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")} + handler := NewAnimeHandler(nil, nil, episodeSvc) + + got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{ + MalID: 59970, + Airing: false, + Episodes: 12, + }}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC)) + + if got.Count != 12 || got.Label != "Total episodes" { + t.Fatalf("animeEpisodeCount() = %+v, want count=12 label=%q", got, "Total episodes") + } +} + func TestAnimeAudioAvailabilityLabel(t *testing.T) { tests := []struct { name string @@ -217,7 +265,7 @@ func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) { if got != tt.want { t.Fatalf("animeAudioAvailability() = %q, want %q", got, tt.want) } - if !episodeSvc.forced { + if !episodeSvc.forceRefresh { t.Fatal("animeAudioAvailability() did not force provider refresh") } })