From ac91bd945e6aedd0771acc5283640d6cca7815b7 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Tue, 16 Jun 2026 18:36:53 +0200 Subject: [PATCH] feat: estimate released episode count for airing anime --- internal/anime/details_handler.go | 19 +++++++ internal/anime/handler_test.go | 87 +++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/internal/anime/details_handler.go b/internal/anime/details_handler.go index 9d19ed6..c4ee6fa 100644 --- a/internal/anime/details_handler.go +++ b/internal/anime/details_handler.go @@ -20,6 +20,23 @@ const ( audioLookupTimeout = 8 * time.Second ) +func releasedEpisodeCount(anime domain.Anime, now time.Time) int { + if !anime.Airing || anime.Aired.From == "" { + return 0 + } + + firstAired, err := time.Parse(time.RFC3339, anime.Aired.From) + if err != nil || now.Before(firstAired) { + return 0 + } + + count := int(now.Sub(firstAired)/(7*24*time.Hour)) + 1 + if anime.Episodes > 0 && count > anime.Episodes { + return anime.Episodes + } + return count +} + func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string { hasKnownSub := false for _, episode := range episodes { @@ -105,6 +122,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { } audioAvailability := h.animeAudioAvailability(c.Request.Context(), anime) + episodesCount := releasedEpisodeCount(anime, time.Now()) c.HTML(http.StatusOK, "anime.gohtml", gin.H{ "Anime": anime, @@ -115,6 +133,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { "WatchlistIDs": watchlistIDs, "ContinueWatchingEp": ep, "ContinueWatchingTime": cwSeconds, + "EpisodesCount": episodesCount, }) } diff --git a/internal/anime/handler_test.go b/internal/anime/handler_test.go index 61b6f42..36434c3 100644 --- a/internal/anime/handler_test.go +++ b/internal/anime/handler_test.go @@ -6,6 +6,7 @@ import ( "mal/integrations/jikan" "mal/internal/domain" "testing" + "time" ) type stubEpisodeService struct { @@ -26,6 +27,92 @@ func (s *stubEpisodeService) RefreshTrackedDue(ctx context.Context, limit int) e return nil } +type releasedCountTest struct { + name string + anime domain.Anime + now time.Time + want int +} + +var releasedCountTests = []releasedCountTest{ + { + name: "weekly airing count", + anime: domain.Anime{Anime: jikan.Anime{ + Airing: true, + Episodes: 24, + Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"}, + }}, + now: time.Date(2026, time.June, 13, 15, 0, 0, 0, time.UTC), + want: 11, + }, + { + name: "before first release", + anime: domain.Anime{Anime: jikan.Anime{ + Airing: true, + Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"}, + }}, + now: time.Date(2026, time.April, 4, 14, 59, 0, 0, time.UTC), + want: 0, + }, + { + name: "first release counts as one", + anime: domain.Anime{Anime: jikan.Anime{ + Airing: true, + Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"}, + }}, + now: time.Date(2026, time.April, 4, 15, 0, 0, 0, time.UTC), + want: 1, + }, + { + name: "caps at total episode count", + anime: domain.Anime{Anime: jikan.Anime{ + Airing: true, + Episodes: 12, + Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"}, + }}, + now: time.Date(2026, time.December, 1, 15, 0, 0, 0, time.UTC), + want: 12, + }, + { + name: "unknown total still estimates current count", + anime: domain.Anime{Anime: jikan.Anime{ + Airing: true, + Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"}, + }}, + now: time.Date(2026, time.April, 18, 15, 0, 0, 0, time.UTC), + want: 3, + }, + { + name: "non airing anime is not estimated", + anime: domain.Anime{Anime: jikan.Anime{ + Airing: false, + Aired: jikan.Aired{From: "2026-04-04T15:00:00+00:00"}, + }}, + now: time.Date(2026, time.April, 18, 15, 0, 0, 0, time.UTC), + want: 0, + }, + { + name: "invalid aired date is ignored", + anime: domain.Anime{Anime: jikan.Anime{ + Airing: true, + Aired: jikan.Aired{From: "not-a-date"}, + }}, + now: time.Date(2026, time.April, 18, 15, 0, 0, 0, time.UTC), + want: 0, + }, +} + +func TestReleasedEpisodeCount(t *testing.T) { + for _, tt := range releasedCountTests { + t.Run(tt.name, func(t *testing.T) { + got := releasedEpisodeCount(tt.anime, tt.now) + if got != tt.want { + t.Fatalf("releasedEpisodeCount() = %d, want %d", got, tt.want) + } + }) + } +} + func TestAnimeAudioAvailabilityLabel(t *testing.T) { tests := []struct { name string