feat: show release info on anime detail page
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -31,8 +31,7 @@
|
||||
</div>
|
||||
{{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}}
|
||||
{{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}}
|
||||
{{template "anime_release_info_loading" dict "AnimeID" $anime.MalID "Count" .ReleaseInfo.Count "Label" .ReleaseInfo.Label "Status" .ReleaseInfo.Status}}
|
||||
{{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}}
|
||||
{{template "anime_audio_availability_loading" dict "AnimeID" $anime.MalID}}
|
||||
|
||||
@@ -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"}}
|
||||
<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"}}
|
||||
{{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">
|
||||
@@ -54,3 +72,14 @@
|
||||
</span>
|
||||
{{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}}
|
||||
|
||||
Reference in New Issue
Block a user