diff --git a/internal/anime/details_handler.go b/internal/anime/details_handler.go index 5a331ccb..665c1130 100644 --- a/internal/anime/details_handler.go +++ b/internal/anime/details_handler.go @@ -10,6 +10,7 @@ import ( "mal/internal/server" "net/http" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -27,15 +28,10 @@ type animeEpisodeCountDisplay struct { Label string } -func listedEpisodeCount(episodes []domain.EpisodeData) int { - count := 0 - for _, episode := range episodes { - if episode.MalID <= 0 || episode.IsRecap { - continue - } - count++ - } - return count +type animeReleaseInfoDisplay struct { + Count int + Label string + Status string } func releasedEpisodeCount(anime domain.Anime, now time.Time) int { @@ -56,15 +52,18 @@ func releasedEpisodeCount(anime domain.Anime, now time.Time) int { } func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime, now time.Time) animeEpisodeCountDisplay { + info := h.animeReleaseInfo(ctx, anime, now) + return animeEpisodeCountDisplay{Count: info.Count, Label: info.Label} +} + +func (h *AnimeHandler) animeReleaseInfo(ctx context.Context, anime domain.Anime, now time.Time) animeReleaseInfoDisplay { 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"} - } + return releaseInfoFromCanonical(anime, episodeList) } else { observability.Warn( "anime_episode_availability_count_fetch_failed", @@ -78,45 +77,63 @@ func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime } } - if h.svc != nil && anime.Airing { - episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout) - defer cancel() + return animeInitialReleaseInfo(anime, now) +} - episodes, err := h.svc.GetAllEpisodes(episodeCtx, anime.MalID) - if err == nil { - if count := listedEpisodeCount(episodes); count > 0 { - return animeEpisodeCountDisplay{Count: count, Label: "Listed episodes"} - } - } else { - observability.Warn( - "anime_episode_count_fetch_failed", - "anime", - "", - map[string]any{ - "anime_id": anime.MalID, - }, - err, - ) - } +func releaseInfoFromCanonical(anime domain.Anime, episodeList domain.CanonicalEpisodeList) animeReleaseInfoDisplay { + info := animeReleaseInfoDisplay{Status: trustedAnimeStatus(anime, len(episodeList.Episodes))} + if count := len(episodeList.Episodes); count > 0 { + info.Count = count + info.Label = canonicalEpisodeCountLabel(episodeList.Source) + } + return info +} + +func canonicalEpisodeCountLabel(source string) string { + if source == "jikan_fallback" || source == "legacy_disabled" { + return "Estimated aired episodes" + } + return "Available episodes" +} + +func animeInitialReleaseInfo(anime domain.Anime, now time.Time) animeReleaseInfoDisplay { + if isCurrentlyAiring(anime) { + return animeReleaseInfoDisplay{} } + info := animeReleaseInfoDisplay{Status: strings.TrimSpace(anime.Status)} if anime.Episodes > 0 { - return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"} + info.Count = anime.Episodes + info.Label = "Total episodes" + return info } if count := releasedEpisodeCount(anime, now); count > 0 { - return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"} + info.Count = count + info.Label = "Estimated aired episodes" } - return animeEpisodeCountDisplay{} + return info } func animeInitialEpisodeCount(anime domain.Anime, now time.Time) animeEpisodeCountDisplay { - if anime.Episodes > 0 { - return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"} + info := animeInitialReleaseInfo(anime, now) + return animeEpisodeCountDisplay{Count: info.Count, Label: info.Label} +} + +func trustedAnimeStatus(anime domain.Anime, canonicalEpisodes int) string { + if canonicalEpisodes == 0 && isCurrentlyAiring(anime) { + return "Not yet aired" } - if count := releasedEpisodeCount(anime, now); count > 0 { - return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"} + if status := strings.TrimSpace(anime.Status); status != "" { + return status } - return animeEpisodeCountDisplay{} + if anime.Airing { + return "Currently Airing" + } + return "" +} + +func isCurrentlyAiring(anime domain.Anime) bool { + return anime.Airing || strings.EqualFold(strings.TrimSpace(anime.Status), "Currently Airing") } func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string { @@ -203,7 +220,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { } } - episodesCount := animeInitialEpisodeCount(anime, time.Now()) + releaseInfo := animeInitialReleaseInfo(anime, time.Now()) c.HTML(http.StatusOK, "anime.gohtml", gin.H{ "Anime": anime, @@ -213,8 +230,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { "WatchlistIDs": watchlistIDs, "ContinueWatchingEp": ep, "ContinueWatchingTime": cwSeconds, - "EpisodesCount": episodesCount.Count, - "EpisodesCountLabel": episodesCount.Label, + "ReleaseInfo": releaseInfo, }) } @@ -265,12 +281,12 @@ func (h *AnimeHandler) loadAnimeDetailsSection(ctx context.Context, id int, sect case "statistics": data, err := h.svc.GetStatistics(ctx, id) return data, "anime_statistics", err - case "episode-count": + case "episode-count", "release-info": anime, err := h.svc.GetAnimeByID(ctx, id) if err != nil { return nil, "", err } - return h.animeEpisodeCount(ctx, anime, time.Now()), "anime_episode_count", nil + return h.animeReleaseInfo(ctx, anime, time.Now()), "anime_release_info", nil case "audio-availability": anime, err := h.svc.GetAnimeByID(ctx, id) if err != nil { diff --git a/internal/anime/handler_test.go b/internal/anime/handler_test.go index 12d2ad74..dd25c41a 100644 --- a/internal/anime/handler_test.go +++ b/internal/anime/handler_test.go @@ -115,20 +115,6 @@ func TestReleasedEpisodeCount(t *testing.T) { } } -func TestListedEpisodeCount(t *testing.T) { - episodes := []domain.EpisodeData{ - {MalID: 1, Title: "Episode 1"}, - {MalID: 2, Title: "Episode 2"}, - {MalID: 3, Title: "Recap", IsRecap: true}, - {Title: "missing id"}, - } - - got := listedEpisodeCount(episodes) - if got != 2 { - t.Fatalf("listedEpisodeCount() = %d, want 2", got) - } -} - func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) { episodeSvc := &stubEpisodeService{ episodes: domain.CanonicalEpisodeList{ @@ -160,6 +146,117 @@ func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) { } } +func TestAnimeReleaseInfoUsesCanonicalEpisodes(t *testing.T) { + episodeSvc := &stubEpisodeService{ + episodes: domain.CanonicalEpisodeList{ + Source: "AllAnime", + Episodes: []domain.CanonicalEpisode{ + {Number: 1}, + {Number: 2}, + {Number: 3}, + }, + }, + } + handler := NewAnimeHandler(nil, nil, episodeSvc, nil) + + got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{ + MalID: 59970, + Status: "Currently Airing", + Airing: true, + Episodes: 12, + }}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC)) + + if got.Count != 3 || got.Label != "Available episodes" || got.Status != "Currently Airing" { + t.Fatalf("animeReleaseInfo() = %+v, want 3 available episodes and current status", got) + } + if episodeSvc.called != 1 { + t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called) + } +} + +func TestAnimeReleaseInfoDoesNotCallJikanFallbackAvailable(t *testing.T) { + episodeSvc := &stubEpisodeService{ + episodes: domain.CanonicalEpisodeList{ + Source: "jikan_fallback", + Episodes: []domain.CanonicalEpisode{ + {Number: 1}, + }, + }, + } + handler := NewAnimeHandler(nil, nil, episodeSvc, nil) + + got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{ + MalID: 62076, + Status: "Currently Airing", + Airing: true, + }}, time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC)) + + if got.Count != 1 || got.Label != "Estimated aired episodes" { + t.Fatalf("animeReleaseInfo() = %+v, want estimated aired episode count", got) + } +} + +func TestAnimeReleaseInfoMarksAiringAnimeWithoutCanonicalEpisodesAsNotYetAired(t *testing.T) { + episodeSvc := &stubEpisodeService{ + episodes: domain.CanonicalEpisodeList{ + Source: "jikan_fallback", + ReleaseChecked: true, + Episodes: []domain.CanonicalEpisode{}, + }, + } + handler := NewAnimeHandler(nil, nil, episodeSvc, nil) + + got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{ + MalID: 62076, + Status: "Currently Airing", + Airing: true, + Episodes: 6, + Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"}, + }}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC)) + + if got.Count != 0 || got.Label != "" || got.Status != "Not yet aired" { + t.Fatalf("animeReleaseInfo() = %+v, want not-yet-aired status without count", got) + } +} + +func TestAnimeEpisodeCountStopsWhenCanonicalEpisodesAreEmpty(t *testing.T) { + episodeSvc := &stubEpisodeService{ + episodes: domain.CanonicalEpisodeList{ + Source: "AllAnime", + Episodes: []domain.CanonicalEpisode{}, + }, + } + handler := NewAnimeHandler(nil, nil, episodeSvc, nil) + + got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{ + MalID: 62076, + Airing: true, + Episodes: 12, + Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"}, + }}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC)) + + if got.Count != 0 || got.Label != "" { + t.Fatalf("animeEpisodeCount() = %+v, want empty display", got) + } + if episodeSvc.called != 1 { + t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called) + } +} + +func TestAnimeInitialReleaseInfoDoesNotTrustCurrentlyAiringMetadata(t *testing.T) { + got := animeInitialReleaseInfo(domain.Anime{Anime: jikan.Anime{ + MalID: 62076, + Status: "Currently Airing", + Airing: true, + Episodes: 6, + Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"}, + }}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC)) + + if got.Count != 0 || got.Label != "" || got.Status != "" { + t.Fatalf("animeInitialReleaseInfo() = %+v, want empty unverified airing display", got) + } +} + func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) { episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")} handler := NewAnimeHandler(nil, nil, episodeSvc, nil) @@ -175,7 +272,7 @@ func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) { } } -func TestAnimeInitialEpisodeCountDoesNotCallEpisodeService(t *testing.T) { +func TestAnimeInitialEpisodeCountDoesNotTrustCurrentlyAiringMetadata(t *testing.T) { episodeSvc := &stubEpisodeService{ episodes: domain.CanonicalEpisodeList{ Episodes: []domain.CanonicalEpisode{{Number: 1}, {Number: 2}, {Number: 3}}, @@ -185,12 +282,13 @@ func TestAnimeInitialEpisodeCountDoesNotCallEpisodeService(t *testing.T) { got := animeInitialEpisodeCount(domain.Anime{Anime: jikan.Anime{ MalID: 59970, Airing: true, + Status: "Currently Airing", 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 != 12 || got.Label != "Total episodes" { - t.Fatalf("animeInitialEpisodeCount() = %+v, want count=12 label=%q", got, "Total episodes") + if got.Count != 0 || got.Label != "" { + t.Fatalf("animeInitialEpisodeCount() = %+v, want empty display", got) } if episodeSvc.called != 0 { t.Fatalf("GetCanonicalEpisodes() calls = %d, want 0", episodeSvc.called) diff --git a/templates/anime.gohtml b/templates/anime.gohtml index b8cd5f9b..f15bd496 100644 --- a/templates/anime.gohtml +++ b/templates/anime.gohtml @@ -31,8 +31,7 @@ {{end}} {{if $anime.Type}}{{$anime.Type}}{{end}} - {{template "anime_episode_count_loading" dict "AnimeID" $anime.MalID "Count" .EpisodesCount "Label" .EpisodesCountLabel}} - {{if $anime.Status}}{{$anime.Status}}{{end}} + {{template "anime_release_info_loading" dict "AnimeID" $anime.MalID "Count" .ReleaseInfo.Count "Label" .ReleaseInfo.Label "Status" .ReleaseInfo.Status}} {{if $anime.Season}}{{$anime.Premiered}}{{end}} {{if $anime.ShortRating}}{{$anime.ShortRating}}{{end}} {{template "anime_audio_availability_loading" dict "AnimeID" $anime.MalID}} diff --git a/templates/components/anime_episode_count.gohtml b/templates/components/anime_episode_count.gohtml index 48e7571a..63f52cd4 100644 --- a/templates/components/anime_episode_count.gohtml +++ b/templates/components/anime_episode_count.gohtml @@ -6,8 +6,6 @@ total eps {{else if eq .Items.Label "Available episodes"}} available eps - {{else if eq .Items.Label "Listed episodes"}} - listed eps {{else}} aired eps {{end}} @@ -15,6 +13,26 @@ {{end}} {{end}} +{{define "anime_release_info"}} + + {{if .Items.Count}} + + {{.Items.Count}} + {{if eq .Items.Label "Total episodes"}} + total eps + {{else if eq .Items.Label "Available episodes"}} + available eps + {{else}} + aired eps + {{end}} + + {{end}} + {{if .Items.Status}} + {{.Items.Status}} + {{end}} + +{{end}} + {{define "anime_audio_availability"}} {{if .Items}} @@ -54,3 +72,14 @@ {{end}} {{end}} + +{{define "anime_release_info_loading"}} + + {{template "anime_release_info" dict "Items" .}} + +{{end}}