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}}