feat: show audio availability on anime detail page
This commit is contained in:
@@ -19,11 +19,13 @@ import (
|
|||||||
const (
|
const (
|
||||||
animeSectionTimeout = 12 * time.Second
|
animeSectionTimeout = 12 * time.Second
|
||||||
watchOrderTimeout = 15 * time.Second
|
watchOrderTimeout = 15 * time.Second
|
||||||
|
audioLookupTimeout = 8 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type AnimeHandler struct {
|
type AnimeHandler struct {
|
||||||
svc Service
|
svc Service
|
||||||
watchlistSvc domain.WatchlistService
|
watchlistSvc domain.WatchlistService
|
||||||
|
episodeSvc domain.EpisodeService
|
||||||
|
|
||||||
scheduleCacheMu sync.Mutex
|
scheduleCacheMu sync.Mutex
|
||||||
scheduleCache map[string]cachedWeekSchedule
|
scheduleCache map[string]cachedWeekSchedule
|
||||||
@@ -37,10 +39,11 @@ type Service interface {
|
|||||||
WarmDetailSections(id int)
|
WarmDetailSections(id int)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService) *AnimeHandler {
|
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService) *AnimeHandler {
|
||||||
return &AnimeHandler{
|
return &AnimeHandler{
|
||||||
svc: svc,
|
svc: svc,
|
||||||
watchlistSvc: watchlistSvc,
|
watchlistSvc: watchlistSvc,
|
||||||
|
episodeSvc: episodeSvc,
|
||||||
scheduleCache: map[string]cachedWeekSchedule{},
|
scheduleCache: map[string]cachedWeekSchedule{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +70,50 @@ func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, an
|
|||||||
return watchlistMap
|
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) {
|
func (h *AnimeHandler) Register(r *gin.Engine) {
|
||||||
r.GET("/", h.HandleCatalog)
|
r.GET("/", h.HandleCatalog)
|
||||||
r.GET("/api/catalog/airing", h.HandleCatalogAiring)
|
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{
|
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
|
||||||
"Anime": anime,
|
"Anime": anime,
|
||||||
|
"AudioAvailability": audioAvailability,
|
||||||
"CurrentPath": fmt.Sprintf("/anime/%d", id),
|
"CurrentPath": fmt.Sprintf("/anime/%d", id),
|
||||||
"User": user,
|
"User": user,
|
||||||
"Status": status,
|
"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}}
|
{{end}}
|
||||||
</div>
|
</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}}
|
{{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>
|
<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}}
|
{{$anime.Score}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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}}
|
{{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}}
|
{{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}}
|
{{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>
|
||||||
|
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
@@ -228,6 +238,12 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
{{if $anime.Source}}
|
{{if $anime.Source}}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user