From c976e99f5a6e4bcebd7bdfcae344e57afef44053 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Mon, 4 May 2026 20:19:58 +0200 Subject: [PATCH] feat: add trailer, characters, and recommendations to anime details --- api/anime/handler.go | 89 +++++++++++++++++++------- integrations/jikan/anime.go | 24 +++++++ integrations/jikan/recommendations.go | 91 --------------------------- integrations/jikan/types.go | 68 ++++++++++++++++++++ templates/anime.gohtml | 78 +++++++++++++++++++---- 5 files changed, 222 insertions(+), 128 deletions(-) delete mode 100644 integrations/jikan/recommendations.go diff --git a/api/anime/handler.go b/api/anime/handler.go index 1305c01..eb642a1 100644 --- a/api/anime/handler.go +++ b/api/anime/handler.go @@ -264,37 +264,78 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) { return } - anime, err := h.jikanClient.GetAnimeByID(r.Context(), id) - if err != nil { + var ( + anime jikan.Anime + characters []jikan.CharacterEntry + recommendations []jikan.RecommendationEntry + watchlist []database.GetUserWatchListRow + status string + ) + + g, gCtx := errgroup.WithContext(r.Context()) + + g.Go(func() error { + var err error + anime, err = h.jikanClient.GetAnimeByID(gCtx, id) + return err + }) + + g.Go(func() error { + var err error + characters, err = h.jikanClient.GetAnimeCharacters(gCtx, id) + if err != nil { + log.Printf("characters fetch error: %v", err) + } + return nil + }) + + g.Go(func() error { + var err error + recommendations, err = h.jikanClient.GetAnimeRecommendations(gCtx, id) + if err != nil { + log.Printf("recommendations fetch error: %v", err) + } + return nil + }) + + user := middleware.GetUser(r.Context()) + if user != nil { + g.Go(func() error { + entry, err := h.db.GetWatchListEntry(gCtx, database.GetWatchListEntryParams{ + UserID: user.ID, + AnimeID: int64(id), + }) + if err == nil { + status = entry.Status + } + return nil + }) + g.Go(func() error { + var err error + watchlist, err = h.db.GetUserWatchList(gCtx, user.ID) + return err + }) + } + + if err := g.Wait(); err != nil { + log.Printf("anime details fetch error: %v", err) renderNotFoundPage(r, w) return } - user := middleware.GetUser(r.Context()) - - var status string - var watchlistIDs []int64 - if user != nil { - entry, err := h.db.GetWatchListEntry(r.Context(), database.GetWatchListEntryParams{ - UserID: user.ID, - AnimeID: int64(id), - }) - if err == nil { - status = entry.Status - } - watchlist, _ := h.db.GetUserWatchList(r.Context(), user.ID) - watchlistIDs = make([]int64, len(watchlist)) - for i, e := range watchlist { - watchlistIDs[i] = e.AnimeID - } + watchlistIDs := make([]int64, len(watchlist)) + for i, e := range watchlist { + watchlistIDs[i] = e.AnimeID } if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "anime.gohtml", map[string]any{ - "Anime": anime, - "User": user, - "Status": status, - "CurrentPath": r.URL.Path, - "WatchlistIDs": watchlistIDs, + "Anime": anime, + "Characters": characters, + "Recommendations": recommendations, + "User": user, + "Status": status, + "CurrentPath": r.URL.Path, + "WatchlistIDs": watchlistIDs, }); err != nil { log.Printf("render error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) diff --git a/integrations/jikan/anime.go b/integrations/jikan/anime.go index 4a8e3e2..589736a 100644 --- a/integrations/jikan/anime.go +++ b/integrations/jikan/anime.go @@ -6,6 +6,30 @@ import ( "time" ) +func (c *Client) GetAnimeCharacters(ctx context.Context, id int) ([]CharacterEntry, error) { + url := fmt.Sprintf("%s/anime/%d/characters", c.baseURL, id) + cacheKey := fmt.Sprintf("anime:characters:%d", id) + + var resp CharactersResponse + if err := c.getWithCache(ctx, cacheKey, 24*time.Hour, url, &resp); err != nil { + return nil, err + } + + return resp.Data, nil +} + +func (c *Client) GetAnimeRecommendations(ctx context.Context, id int) ([]RecommendationEntry, error) { + url := fmt.Sprintf("%s/anime/%d/recommendations", c.baseURL, id) + cacheKey := fmt.Sprintf("anime:recommendations:%d", id) + + var resp RecommendationsResponse + if err := c.getWithCache(ctx, cacheKey, 24*time.Hour, url, &resp); err != nil { + return nil, err + } + + return resp.Data, nil +} + func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) { cacheKey := fmt.Sprintf("anime:%d", id) diff --git a/integrations/jikan/recommendations.go b/integrations/jikan/recommendations.go deleted file mode 100644 index f2b31e1..0000000 --- a/integrations/jikan/recommendations.go +++ /dev/null @@ -1,91 +0,0 @@ -package jikan - -import ( - "context" - "fmt" - "time" -) - -type RecommendationEntry struct { - Entry struct { - MalID int `json:"mal_id"` - URL string `json:"url"` - Images struct { - Webp struct { - LargeImageURL string `json:"large_image_url"` - } `json:"webp"` - } `json:"images"` - Title string `json:"title"` - } `json:"entry"` - Votes int `json:"votes"` -} - -type RecommendationsResponse struct { - Data []RecommendationEntry `json:"data"` -} - -func (c *Client) GetRecommendations(ctx context.Context, animeID int, limit int) ([]Anime, error) { - cacheKey := fmt.Sprintf("recs:%d", animeID) - - var cached []Anime - if c.getCache(ctx, cacheKey, &cached) { - if limit > 0 && len(cached) > limit { - return cached[:limit], nil - } - return cached, nil - } - - var result RecommendationsResponse - reqURL := fmt.Sprintf("%s/anime/%d/recommendations", c.baseURL, animeID) - - if err := c.fetchWithRetry(ctx, reqURL, &result); err != nil { - var stale []Anime - if c.getStaleCache(ctx, cacheKey, &stale) { - if limit > 0 && len(stale) > limit { - return stale[:limit], nil - } - return stale, nil - } - return nil, err - } - - max := len(result.Data) - if limit > 0 && max > limit { - max = limit - } - - animes := make([]Anime, 0, max) - for i := 0; i < max; i++ { - rec := result.Data[i] - - var fullAnime Anime - animeCacheKey := fmt.Sprintf("anime:%d", rec.Entry.MalID) - - if c.getCache(ctx, animeCacheKey, &fullAnime) { - animes = append(animes, fullAnime) - } else { - anime := Anime{ - MalID: rec.Entry.MalID, - Title: rec.Entry.Title, - Images: struct { - Jpg struct { - LargeImageURL string `json:"large_image_url"` - } `json:"jpg"` - Webp struct { - LargeImageURL string `json:"large_image_url"` - } `json:"webp"` - }{ - Webp: struct { - LargeImageURL string `json:"large_image_url"` - }{ - LargeImageURL: rec.Entry.Images.Webp.LargeImageURL, - }, - }, - } - animes = append(animes, anime) - } - } - - c.setCache(ctx, cacheKey, animes, time.Hour*24) - return animes, nil -} diff --git a/integrations/jikan/types.go b/integrations/jikan/types.go index 38af3c7..bba71f1 100644 --- a/integrations/jikan/types.go +++ b/integrations/jikan/types.go @@ -76,6 +76,18 @@ type Anime struct { Timezone string `json:"timezone"` String string `json:"string"` } `json:"broadcast"` + Trailer struct { + YoutubeID string `json:"youtube_id"` + URL string `json:"url"` + EmbedURL string `json:"embed_url"` + Images struct { + ImageURL string `json:"image_url"` + SmallImageURL string `json:"small_image_url"` + MediumImageURL string `json:"medium_image_url"` + LargeImageURL string `json:"large_image_url"` + MaximumImageURL string `json:"maximum_image_url"` + } `json:"images"` + } `json:"trailer"` Streaming []struct { Name string `json:"name"` URL string `json:"url"` @@ -87,6 +99,62 @@ type Anime struct { } `json:"external"` } +type CharacterVoiceActor struct { + Person struct { + MalID int `json:"mal_id"` + URL string `json:"url"` + Images struct { + Jpg struct { + ImageURL string `json:"image_url"` + } `json:"jpg"` + } `json:"images"` + Name string `json:"name"` + } `json:"person"` + Language string `json:"language"` +} + +type CharacterEntry struct { + Character struct { + MalID int `json:"mal_id"` + URL string `json:"url"` + Images struct { + Jpg struct { + ImageURL string `json:"image_url"` + } `json:"jpg"` + Webp struct { + ImageURL string `json:"image_url"` + SmallImageURL string `json:"small_image_url"` + } `json:"webp"` + } `json:"images"` + Name string `json:"name"` + } `json:"character"` + Role string `json:"role"` + VoiceActors []CharacterVoiceActor `json:"voice_actors"` +} + +type CharactersResponse struct { + Data []CharacterEntry `json:"data"` +} + +type RecommendationEntry struct { + Entry struct { + MalID int `json:"mal_id"` + URL string `json:"url"` + Images struct { + Webp struct { + LargeImageURL string `json:"large_image_url"` + } `json:"webp"` + } `json:"images"` + Title string `json:"title"` + } `json:"entry"` + URL string `json:"url"` + Votes int `json:"votes"` +} + +type RecommendationsResponse struct { + Data []RecommendationEntry `json:"data"` +} + func (a Anime) ScoredByFormatted() string { return formatNumber(a.ScoredBy) } diff --git a/templates/anime.gohtml b/templates/anime.gohtml index 7a92dfa..df81aea 100644 --- a/templates/anime.gohtml +++ b/templates/anime.gohtml @@ -43,10 +43,10 @@ {{template "watchlist_actions" dict "Anime" $anime "User" .User "Status" .Status}} -
-
-
-

Synopsis

+
+
+
+

Synopsis

{{if $anime.Synopsis}}{{$anime.Synopsis}}{{else}}No synopsis available.{{end}}

{{if and $anime.Synopsis (gt (len $anime.Synopsis) 400)}}
- {{end}}
-
+ {{if $anime.Trailer.YoutubeID}} +
+

Trailer

+
+ +
+
+ {{end}} + + {{if $anime.Background}} +
+

Background

+
+ {{$anime.Background}} +
+
+ {{end}} +