feat: add trailer, characters, and recommendations to anime details
This commit is contained in:
@@ -264,37 +264,78 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
anime, err := h.jikanClient.GetAnimeByID(r.Context(), id)
|
var (
|
||||||
if err != nil {
|
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)
|
renderNotFoundPage(r, w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := middleware.GetUser(r.Context())
|
watchlistIDs := make([]int64, len(watchlist))
|
||||||
|
for i, e := range watchlist {
|
||||||
var status string
|
watchlistIDs[i] = e.AnimeID
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "anime.gohtml", map[string]any{
|
if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "anime.gohtml", map[string]any{
|
||||||
"Anime": anime,
|
"Anime": anime,
|
||||||
"User": user,
|
"Characters": characters,
|
||||||
"Status": status,
|
"Recommendations": recommendations,
|
||||||
"CurrentPath": r.URL.Path,
|
"User": user,
|
||||||
"WatchlistIDs": watchlistIDs,
|
"Status": status,
|
||||||
|
"CurrentPath": r.URL.Path,
|
||||||
|
"WatchlistIDs": watchlistIDs,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("render error: %v", err)
|
log.Printf("render error: %v", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -6,6 +6,30 @@ import (
|
|||||||
"time"
|
"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) {
|
func (c *Client) GetAnimeByID(ctx context.Context, id int) (Anime, error) {
|
||||||
cacheKey := fmt.Sprintf("anime:%d", id)
|
cacheKey := fmt.Sprintf("anime:%d", id)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -76,6 +76,18 @@ type Anime struct {
|
|||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
String string `json:"string"`
|
String string `json:"string"`
|
||||||
} `json:"broadcast"`
|
} `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 {
|
Streaming []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
@@ -87,6 +99,62 @@ type Anime struct {
|
|||||||
} `json:"external"`
|
} `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 {
|
func (a Anime) ScoredByFormatted() string {
|
||||||
return formatNumber(a.ScoredBy)
|
return formatNumber(a.ScoredBy)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,10 +43,10 @@
|
|||||||
|
|
||||||
{{template "watchlist_actions" dict "Anime" $anime "User" .User "Status" .Status}}
|
{{template "watchlist_actions" dict "Anime" $anime "User" .User "Status" .Status}}
|
||||||
|
|
||||||
<div class="mt-12 flex flex-col gap-12 lg:flex-row">
|
<div class="flex flex-col gap-12 lg:flex-row">
|
||||||
<div class="grow">
|
<div class="grow lg:max-w-4xl">
|
||||||
<section class="max-w-4xl">
|
<section>
|
||||||
<h2 class="mb-4 text-lg font-normal text-neutral-300">Synopsis</h2>
|
<h2 class="mb-4 mt-2 text-lg font-normal text-neutral-300">Synopsis</h2>
|
||||||
<p id="synopsis-container" class="text-neutral-400 text-base leading-relaxed line-clamp-6 md:line-clamp-none">{{if $anime.Synopsis}}{{$anime.Synopsis}}{{else}}No synopsis available.{{end}}</p>
|
<p id="synopsis-container" class="text-neutral-400 text-base leading-relaxed line-clamp-6 md:line-clamp-none">{{if $anime.Synopsis}}{{$anime.Synopsis}}{{else}}No synopsis available.{{end}}</p>
|
||||||
{{if and $anime.Synopsis (gt (len $anime.Synopsis) 400)}}
|
{{if and $anime.Synopsis (gt (len $anime.Synopsis) 400)}}
|
||||||
<button id="synopsis-toggle" class="mt-2 text-sm font-normal text-neutral-400 transition-colors hover:text-white md:hidden" onclick="
|
<button id="synopsis-toggle" class="mt-2 text-sm font-normal text-neutral-400 transition-colors hover:text-white md:hidden" onclick="
|
||||||
@@ -63,17 +63,31 @@
|
|||||||
Read more
|
Read more
|
||||||
</button>
|
</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if $anime.Background}}
|
|
||||||
<section class="max-w-4xl mt-12">
|
|
||||||
<h2 class="mb-4 text-lg font-normal text-neutral-300">Background</h2>
|
|
||||||
<div class="text-neutral-400 text-sm leading-relaxed whitespace-pre-line border-l border-white/5 pl-6 italic">
|
|
||||||
{{$anime.Background}}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{{if $anime.Trailer.YoutubeID}}
|
||||||
|
<section class="mt-12">
|
||||||
|
<h2 class="mb-6 text-lg font-normal text-neutral-300">Trailer</h2>
|
||||||
|
<div class="aspect-video w-full bg-white/5 ring-1 ring-white/10">
|
||||||
|
<iframe
|
||||||
|
src="https://www.youtube.com/embed/{{$anime.Trailer.YoutubeID}}"
|
||||||
|
class="h-full w-full"
|
||||||
|
allowfullscreen
|
||||||
|
loading="lazy">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if $anime.Background}}
|
||||||
|
<section class="mt-12">
|
||||||
|
<h2 class="mb-4 text-lg font-normal text-neutral-300">Background</h2>
|
||||||
|
<div class="border-l border-white/5 pl-6 text-sm italic leading-relaxed text-neutral-400 whitespace-pre-line">
|
||||||
|
{{$anime.Background}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
<aside class="fixed right-0 top-0 hidden h-screen w-80 shrink-0 flex-col overflow-y-auto border-l border-white/5 bg-neutral-950 p-10 lg:flex">
|
<aside class="fixed right-0 top-0 hidden h-screen w-80 shrink-0 flex-col overflow-y-auto border-l border-white/5 bg-neutral-950 p-10 lg:flex">
|
||||||
<div class="flex flex-col gap-10">
|
<div class="flex flex-col gap-10">
|
||||||
<section>
|
<section>
|
||||||
@@ -183,6 +197,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .Characters}}
|
||||||
|
<div class="mt-12 w-full">
|
||||||
|
<h2 class="mb-6 text-lg font-normal text-neutral-300">Characters & Cast</h2>
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
|
{{range (slice .Characters 0 (min (len .Characters) 10))}}
|
||||||
|
<div class="flex gap-3 bg-white/[0.02] p-3 ring-1 ring-white/5">
|
||||||
|
<div class="h-16 w-12 shrink-0 overflow-hidden bg-white/5">
|
||||||
|
<img src="{{.Character.Images.Jpg.ImageURL}}" alt="{{.Character.Name}}" class="h-full w-full object-cover" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col justify-center overflow-hidden">
|
||||||
|
<span class="truncate text-sm font-medium text-neutral-200">{{.Character.Name}}</span>
|
||||||
|
<span class="truncate text-xs text-neutral-500">{{.Role}}</span>
|
||||||
|
{{if .VoiceActors}}
|
||||||
|
<span class="mt-1 truncate text-[11px] text-neutral-400">{{(index .VoiceActors 0).Person.Name}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div hx-get="/api/watch-order?animeId={{$anime.MalID}}" hx-trigger="load">
|
<div hx-get="/api/watch-order?animeId={{$anime.MalID}}" hx-trigger="load">
|
||||||
<div class="mt-8 flex items-center gap-3 text-neutral-400">
|
<div class="mt-8 flex items-center gap-3 text-neutral-400">
|
||||||
@@ -191,5 +227,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .Recommendations}}
|
||||||
|
<div class="mt-12 w-full">
|
||||||
|
<h2 class="mb-6 text-lg font-normal text-neutral-300">Recommendations</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8">
|
||||||
|
{{range (slice .Recommendations 0 (min (len .Recommendations) 8))}}
|
||||||
|
<a href="/anime/{{.Entry.MalID}}" class="group flex flex-col gap-2">
|
||||||
|
<div class="aspect-2/3 overflow-hidden bg-white/5 shadow-md">
|
||||||
|
<img src="{{.Entry.Images.Webp.LargeImageURL}}" alt="{{.Entry.Title}}" class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<span class="truncate text-xs font-medium text-neutral-400 transition-colors group-hover:text-white">{{.Entry.Title}}</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user