From 7ebfe4807b0864b9e9c825a6532cef72174e120b Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 5 Jun 2026 13:20:21 +0200 Subject: [PATCH] feat: show audio availability on anime detail page --- internal/anime/handler.go | 52 +++++++++++++- internal/anime/handler_test.go | 124 +++++++++++++++++++++++++++++++++ templates/anime.gohtml | 32 ++++++--- 3 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 internal/anime/handler_test.go diff --git a/internal/anime/handler.go b/internal/anime/handler.go index e01d608..d1d9c48 100644 --- a/internal/anime/handler.go +++ b/internal/anime/handler.go @@ -19,11 +19,13 @@ import ( const ( animeSectionTimeout = 12 * time.Second watchOrderTimeout = 15 * time.Second + audioLookupTimeout = 8 * time.Second ) type AnimeHandler struct { svc Service watchlistSvc domain.WatchlistService + episodeSvc domain.EpisodeService scheduleCacheMu sync.Mutex scheduleCache map[string]cachedWeekSchedule @@ -37,10 +39,11 @@ type Service interface { WarmDetailSections(id int) } -func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService) *AnimeHandler { +func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService) *AnimeHandler { return &AnimeHandler{ svc: svc, watchlistSvc: watchlistSvc, + episodeSvc: episodeSvc, scheduleCache: map[string]cachedWeekSchedule{}, } } @@ -67,6 +70,50 @@ func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, an return watchlistMap } +func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string { + hasKnownSub := false + for _, episode := range episodes { + if episode.HasDub { + return "Dub available" + } + if episode.HasSub || episode.SubOnly { + hasKnownSub = true + } + } + if hasKnownSub { + return "Subtitled only" + } + return "" +} + +func (h *AnimeHandler) animeAudioAvailability(ctx context.Context, anime domain.Anime) string { + if h.episodeSvc == nil { + return "" + } + + audioCtx, cancel := context.WithTimeout(ctx, audioLookupTimeout) + defer cancel() + + episodeList, err := h.episodeSvc.GetCanonicalEpisodes(audioCtx, anime, true) + if err != nil { + observability.Warn( + "anime_audio_availability_fetch_failed", + "anime", + "", + map[string]any{ + "anime_id": anime.MalID, + }, + err, + ) + return "" + } + if episodeList.Source != "AllAnime" { + return "" + } + + return animeAudioAvailabilityLabel(episodeList.Episodes) +} + func (h *AnimeHandler) Register(r *gin.Engine) { r.GET("/", h.HandleCatalog) r.GET("/api/catalog/airing", h.HandleCatalogAiring) @@ -565,8 +612,11 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { } } + audioAvailability := h.animeAudioAvailability(c.Request.Context(), anime) + c.HTML(http.StatusOK, "anime.gohtml", gin.H{ "Anime": anime, + "AudioAvailability": audioAvailability, "CurrentPath": fmt.Sprintf("/anime/%d", id), "User": user, "Status": status, diff --git a/internal/anime/handler_test.go b/internal/anime/handler_test.go new file mode 100644 index 0000000..61b6f42 --- /dev/null +++ b/internal/anime/handler_test.go @@ -0,0 +1,124 @@ +package anime + +import ( + "context" + "errors" + "mal/integrations/jikan" + "mal/internal/domain" + "testing" +) + +type stubEpisodeService struct { + episodes domain.CanonicalEpisodeList + err error + forced bool +} + +func (s *stubEpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) { + s.forced = forceRefresh + if s.err != nil { + return domain.CanonicalEpisodeList{}, s.err + } + return s.episodes, nil +} + +func (s *stubEpisodeService) RefreshTrackedDue(ctx context.Context, limit int) error { + return nil +} + +func TestAnimeAudioAvailabilityLabel(t *testing.T) { + tests := []struct { + name string + episodes []domain.CanonicalEpisode + want string + }{ + { + name: "dub availability", + episodes: []domain.CanonicalEpisode{ + {Number: 1, HasSub: true, HasDub: true}, + }, + want: "Dub available", + }, + { + name: "subtitled availability", + episodes: []domain.CanonicalEpisode{ + {Number: 1, HasSub: true, SubOnly: true}, + }, + want: "Subtitled only", + }, + { + name: "unknown availability", + episodes: []domain.CanonicalEpisode{{Number: 1}}, + want: "", + }, + { + name: "no episodes", + episodes: []domain.CanonicalEpisode{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := animeAudioAvailabilityLabel(tt.episodes) + if got != tt.want { + t.Fatalf("animeAudioAvailabilityLabel() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) { + tests := []struct { + name string + source string + err error + want string + }{ + { + name: "allanime source", + source: "AllAnime", + want: "Dub available", + }, + { + name: "jikan fallback source", + source: "jikan_fallback", + want: "", + }, + { + name: "legacy source", + source: "legacy_disabled", + want: "", + }, + { + name: "provider error", + err: errors.New("provider unavailable"), + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + episodeSvc := &stubEpisodeService{ + episodes: domain.CanonicalEpisodeList{ + Source: tt.source, + Episodes: []domain.CanonicalEpisode{ + {Number: 1, HasSub: true, HasDub: true}, + }, + }, + err: tt.err, + } + handler := NewAnimeHandler(nil, nil, episodeSvc) + + got := handler.animeAudioAvailability(context.Background(), domain.Anime{ + Anime: jikan.Anime{MalID: 52991}, + }) + if got != tt.want { + t.Fatalf("animeAudioAvailability() = %q, want %q", got, tt.want) + } + if !episodeSvc.forced { + t.Fatal("animeAudioAvailability() did not force provider refresh") + } + }) + } +} diff --git a/templates/anime.gohtml b/templates/anime.gohtml index 682fb8c..36cdb18 100644 --- a/templates/anime.gohtml +++ b/templates/anime.gohtml @@ -139,22 +139,32 @@ {{end}} -
+
{{if $anime.Score}} -
+
{{$anime.Score}}
{{end}} - {{if $anime.Type}}{{$anime.Type}}{{end}} + {{if $anime.Type}}{{$anime.Type}}{{end}} {{if and $anime.Airing .EpisodesCount}} - {{.EpisodesCount}}{{if $anime.Episodes}}/{{$anime.Episodes}}{{end}} episodes + {{.EpisodesCount}}{{if $anime.Episodes}}/{{$anime.Episodes}}{{end}} episodes {{else if $anime.Episodes}} - {{$anime.Episodes}} episodes + {{$anime.Episodes}} episodes + {{end}} + {{if $anime.Status}}{{$anime.Status}}{{end}} + {{if $anime.Season}}{{$anime.Premiered}}{{end}} + {{if $anime.ShortRating}}{{$anime.ShortRating}}{{end}} + {{if .AudioAvailability}} + + + {{.AudioAvailability}} + {{end}} - {{if $anime.Status}}{{$anime.Status}}{{end}} - {{if $anime.Season}}{{$anime.Premiered}}{{end}} - {{if $anime.ShortRating}}{{$anime.ShortRating}}{{end}}
@@ -228,6 +238,12 @@
{{end}} + {{if .AudioAvailability}} +
+
Audio
+
{{.AudioAvailability}}
+
+ {{end}}
{{if $anime.Source}}