feat: show audio availability on anime detail page

This commit is contained in:
2026-06-05 13:20:21 +02:00
parent 9d58adea9c
commit 356ac99c64
3 changed files with 199 additions and 9 deletions

View File

@@ -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,

View File

@@ -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")
}
})
}
}