feat: show release info on anime detail page

This commit is contained in:
2026-07-01 18:53:35 +02:00
parent d61ed31d1e
commit 08f58cf377
4 changed files with 207 additions and 65 deletions

View File

@@ -10,6 +10,7 @@ import (
"mal/internal/server" "mal/internal/server"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -27,15 +28,10 @@ type animeEpisodeCountDisplay struct {
Label string Label string
} }
func listedEpisodeCount(episodes []domain.EpisodeData) int { type animeReleaseInfoDisplay struct {
count := 0 Count int
for _, episode := range episodes { Label string
if episode.MalID <= 0 || episode.IsRecap { Status string
continue
}
count++
}
return count
} }
func releasedEpisodeCount(anime domain.Anime, now time.Time) int { 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 { 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 { if h.episodeSvc != nil {
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout) episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
defer cancel() defer cancel()
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(episodeCtx, anime, false) episodeList, err := h.episodeSvc.GetCanonicalEpisodes(episodeCtx, anime, false)
if err == nil { if err == nil {
if count := len(episodeList.Episodes); count > 0 { return releaseInfoFromCanonical(anime, episodeList)
return animeEpisodeCountDisplay{Count: count, Label: "Available episodes"}
}
} else { } else {
observability.Warn( observability.Warn(
"anime_episode_availability_count_fetch_failed", "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 { return animeInitialReleaseInfo(anime, now)
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout) }
defer cancel()
episodes, err := h.svc.GetAllEpisodes(episodeCtx, anime.MalID) func releaseInfoFromCanonical(anime domain.Anime, episodeList domain.CanonicalEpisodeList) animeReleaseInfoDisplay {
if err == nil { info := animeReleaseInfoDisplay{Status: trustedAnimeStatus(anime, len(episodeList.Episodes))}
if count := listedEpisodeCount(episodes); count > 0 { if count := len(episodeList.Episodes); count > 0 {
return animeEpisodeCountDisplay{Count: count, Label: "Listed episodes"} info.Count = count
} info.Label = canonicalEpisodeCountLabel(episodeList.Source)
} else { }
observability.Warn( return info
"anime_episode_count_fetch_failed", }
"anime",
"", func canonicalEpisodeCountLabel(source string) string {
map[string]any{ if source == "jikan_fallback" || source == "legacy_disabled" {
"anime_id": anime.MalID, return "Estimated aired episodes"
}, }
err, 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 { 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 { 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 { func animeInitialEpisodeCount(anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
if anime.Episodes > 0 { info := animeInitialReleaseInfo(anime, now)
return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"} 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 { if status := strings.TrimSpace(anime.Status); status != "" {
return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"} 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 { 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{ c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"Anime": anime, "Anime": anime,
@@ -213,8 +230,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
"WatchlistIDs": watchlistIDs, "WatchlistIDs": watchlistIDs,
"ContinueWatchingEp": ep, "ContinueWatchingEp": ep,
"ContinueWatchingTime": cwSeconds, "ContinueWatchingTime": cwSeconds,
"EpisodesCount": episodesCount.Count, "ReleaseInfo": releaseInfo,
"EpisodesCountLabel": episodesCount.Label,
}) })
} }
@@ -265,12 +281,12 @@ func (h *AnimeHandler) loadAnimeDetailsSection(ctx context.Context, id int, sect
case "statistics": case "statistics":
data, err := h.svc.GetStatistics(ctx, id) data, err := h.svc.GetStatistics(ctx, id)
return data, "anime_statistics", err return data, "anime_statistics", err
case "episode-count": case "episode-count", "release-info":
anime, err := h.svc.GetAnimeByID(ctx, id) anime, err := h.svc.GetAnimeByID(ctx, id)
if err != nil { if err != nil {
return nil, "", err 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": case "audio-availability":
anime, err := h.svc.GetAnimeByID(ctx, id) anime, err := h.svc.GetAnimeByID(ctx, id)
if err != nil { if err != nil {

View File

@@ -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) { func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
episodeSvc := &stubEpisodeService{ episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{ 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) { func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) {
episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")} episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")}
handler := NewAnimeHandler(nil, nil, episodeSvc, nil) 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{ episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{ episodes: domain.CanonicalEpisodeList{
Episodes: []domain.CanonicalEpisode{{Number: 1}, {Number: 2}, {Number: 3}}, 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{ got := animeInitialEpisodeCount(domain.Anime{Anime: jikan.Anime{
MalID: 59970, MalID: 59970,
Airing: true, Airing: true,
Status: "Currently Airing",
Episodes: 12, Episodes: 12,
Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"}, Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"},
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC)) }}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
if got.Count != 12 || got.Label != "Total episodes" { if got.Count != 0 || got.Label != "" {
t.Fatalf("animeInitialEpisodeCount() = %+v, want count=12 label=%q", got, "Total episodes") t.Fatalf("animeInitialEpisodeCount() = %+v, want empty display", got)
} }
if episodeSvc.called != 0 { if episodeSvc.called != 0 {
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 0", episodeSvc.called) t.Fatalf("GetCanonicalEpisodes() calls = %d, want 0", episodeSvc.called)

View File

@@ -31,8 +31,7 @@
</div> </div>
{{end}} {{end}}
{{if $anime.Type}}<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-0.75 before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">{{$anime.Type}}</span>{{end}} {{if $anime.Type}}<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-0.75 before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">{{$anime.Type}}</span>{{end}}
{{template "anime_episode_count_loading" dict "AnimeID" $anime.MalID "Count" .EpisodesCount "Label" .EpisodesCountLabel}} {{template "anime_release_info_loading" dict "AnimeID" $anime.MalID "Count" .ReleaseInfo.Count "Label" .ReleaseInfo.Label "Status" .ReleaseInfo.Status}}
{{if $anime.Status}}<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-0.75 before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">{{$anime.Status}}</span>{{end}}
{{if $anime.Season}}<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-0.75 before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">{{$anime.Premiered}}</span>{{end}} {{if $anime.Season}}<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-0.75 before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">{{$anime.Premiered}}</span>{{end}}
{{if $anime.ShortRating}}<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-0.75 before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">{{$anime.ShortRating}}</span>{{end}} {{if $anime.ShortRating}}<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-0.75 before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">{{$anime.ShortRating}}</span>{{end}}
{{template "anime_audio_availability_loading" dict "AnimeID" $anime.MalID}} {{template "anime_audio_availability_loading" dict "AnimeID" $anime.MalID}}

View File

@@ -6,8 +6,6 @@
total eps total eps
{{else if eq .Items.Label "Available episodes"}} {{else if eq .Items.Label "Available episodes"}}
available eps available eps
{{else if eq .Items.Label "Listed episodes"}}
listed eps
{{else}} {{else}}
aired eps aired eps
{{end}} {{end}}
@@ -15,6 +13,26 @@
{{end}} {{end}}
{{end}} {{end}}
{{define "anime_release_info"}}
<span class="contents">
{{if .Items.Count}}
<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-0.75 before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">
<span class="text-foreground">{{.Items.Count}}</span>
{{if eq .Items.Label "Total episodes"}}
total eps
{{else if eq .Items.Label "Available episodes"}}
available eps
{{else}}
aired eps
{{end}}
</span>
{{end}}
{{if .Items.Status}}
<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-0.75 before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">{{.Items.Status}}</span>
{{end}}
</span>
{{end}}
{{define "anime_audio_availability"}} {{define "anime_audio_availability"}}
{{if .Items}} {{if .Items}}
<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-0.75 before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden"> <span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-0.75 before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">
@@ -54,3 +72,14 @@
</span> </span>
{{end}} {{end}}
{{end}} {{end}}
{{define "anime_release_info_loading"}}
<span
hx-get="/anime/{{.AnimeID}}?section=release-info"
hx-trigger="load"
hx-swap="outerHTML"
class="contents"
>
{{template "anime_release_info" dict "Items" .}}
</span>
{{end}}