feat: show audio availability on anime detail page
This commit is contained in:
@@ -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,
|
||||
|
||||
124
internal/anime/handler_test.go
Normal file
124
internal/anime/handler_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -139,22 +139,32 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="mb-6 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-foreground-muted">
|
||||
<div class="mb-6 flex flex-wrap items-center gap-x-2.5 gap-y-2 text-sm text-foreground-muted">
|
||||
{{if $anime.Score}}
|
||||
<div class="flex items-center gap-1.5 font-medium text-foreground">
|
||||
<div class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-[3px] before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden font-medium text-foreground">
|
||||
<svg class="h-3.5 w-3.5 fill-current text-accent" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/></svg>
|
||||
{{$anime.Score}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if $anime.Type}}<span class="flex items-center gap-1.5"><span>•</span>{{$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-[3px] before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">{{$anime.Type}}</span>{{end}}
|
||||
{{if and $anime.Airing .EpisodesCount}}
|
||||
<span class="flex items-center gap-1.5"><span>•</span>{{.EpisodesCount}}{{if $anime.Episodes}}/{{$anime.Episodes}}{{end}} episodes</span>
|
||||
<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-[3px] before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">{{.EpisodesCount}}{{if $anime.Episodes}}/{{$anime.Episodes}}{{end}} episodes</span>
|
||||
{{else if $anime.Episodes}}
|
||||
<span class="flex items-center gap-1.5"><span>•</span>{{$anime.Episodes}} episodes</span>
|
||||
<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-[3px] before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">{{$anime.Episodes}} episodes</span>
|
||||
{{end}}
|
||||
{{if $anime.Status}}<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-[3px] 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-[3px] 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-[3px] before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">{{$anime.ShortRating}}</span>{{end}}
|
||||
{{if .AudioAvailability}}
|
||||
<span class="flex min-w-0 items-center gap-1.5 before:mr-1 before:block before:size-[3px] before:shrink-0 before:rounded-full before:bg-current before:opacity-65 first:before:hidden">
|
||||
<svg class="size-3.5 shrink-0 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 14a9 9 0 0 1 18 0"></path>
|
||||
<path d="M21 14v3a2 2 0 0 1-2 2h-1v-7h1a2 2 0 0 1 2 2Z"></path>
|
||||
<path d="M3 14v3a2 2 0 0 0 2 2h1v-7H5a2 2 0 0 0-2 2Z"></path>
|
||||
</svg>
|
||||
{{.AudioAvailability}}
|
||||
</span>
|
||||
{{end}}
|
||||
{{if $anime.Status}}<span class="flex items-center gap-1.5"><span>•</span>{{$anime.Status}}</span>{{end}}
|
||||
{{if $anime.Season}}<span class="flex items-center gap-1.5"><span>•</span>{{$anime.Premiered}}</span>{{end}}
|
||||
{{if $anime.ShortRating}}<span class="flex items-center gap-1.5"><span>•</span>{{$anime.ShortRating}}</span>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
@@ -228,6 +238,12 @@
|
||||
</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .AudioAvailability}}
|
||||
<div>
|
||||
<dt class="mb-1 text-xs font-normal text-foreground-muted">Audio</dt>
|
||||
<dd class="text-foreground">{{.AudioAvailability}}</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{{if $anime.Source}}
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user