diff --git a/internal/features/anime/handler.go b/internal/features/anime/handler.go index ddbf1dc..99ba187 100644 --- a/internal/features/anime/handler.go +++ b/internal/features/anime/handler.go @@ -207,11 +207,41 @@ func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) { return } templates.AnimeRecommendations(recs).Render(r.Context(), w) + case "episodes": + currentEpisode := r.URL.Query().Get("current") + episodes, err := h.svc.GetEpisodes(r.Context(), id) + if err != nil { + log.Printf("episodes error for %d: %v", id, err) + writeInlineLoadError(w, "Failed to load episodes.") + return + } + templates.EpisodeList(episodes, currentEpisode, id).Render(r.Context(), w) default: renderNotFoundPage(r, w) } } +func (h *Handler) HandleAPIEpisodes(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path[len("/api/episodes/"):] + path = strings.Trim(path, "/") + + id, err := strconv.Atoi(path) + if err != nil || id <= 0 { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + currentEpisode := r.URL.Query().Get("current") + episodes, err := h.svc.GetEpisodes(r.Context(), id) + if err != nil { + log.Printf("episodes error for %d: %v", id, err) + writeInlineLoadError(w, "Failed to load episodes.") + return + } + + templates.EpisodeList(episodes, currentEpisode, id).Render(r.Context(), w) +} + func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/internal/features/anime/service.go b/internal/features/anime/service.go index caf2958..9c84f80 100644 --- a/internal/features/anime/service.go +++ b/internal/features/anime/service.go @@ -144,3 +144,28 @@ func (s *Service) GetAnimeByProducer(ctx context.Context, producerID int, page i func (s *Service) GetProducerByID(ctx context.Context, producerID int) (jikan.ProducerResponse, error) { return s.jikanClient.GetProducerByID(ctx, producerID) } + +func (s *Service) GetEpisodes(ctx context.Context, animeID int) ([]jikan.Episode, error) { + var allEpisodes []jikan.Episode + page := 1 + + for page <= 20 { + result, err := s.jikanClient.GetEpisodes(ctx, animeID, page) + if err != nil { + if jikan.IsRetryableError(err) && len(allEpisodes) > 0 { + // Return what we have if we're getting rate limited + return allEpisodes, nil + } + return nil, err + } + + allEpisodes = append(allEpisodes, result.Data...) + + if !result.Pagination.HasNextPage { + break + } + page++ + } + + return allEpisodes, nil +} diff --git a/internal/jikan/episodes.go b/internal/jikan/episodes.go new file mode 100644 index 0000000..5ee7b85 --- /dev/null +++ b/internal/jikan/episodes.go @@ -0,0 +1,35 @@ +package jikan + +import ( + "context" + "fmt" + "time" +) + +func (c *Client) GetEpisodes(ctx context.Context, animeID int, page int) (EpisodesResponse, error) { + if page < 1 { + page = 1 + } + + cacheKey := fmt.Sprintf("anime:%d:episodes:%d", animeID, page) + var cached EpisodesResponse + if c.getCache(ctx, cacheKey, &cached) { + return cached, nil + } + + var stale EpisodesResponse + hasStale := c.getStaleCache(ctx, cacheKey, &stale) + + var result EpisodesResponse + reqURL := fmt.Sprintf("%s/anime/%d/episodes?page=%d", c.baseURL, animeID, page) + if err := c.fetchWithRetry(ctx, reqURL, &result); err != nil { + if hasStale { + return stale, nil + } + + return EpisodesResponse{}, err + } + + c.setCache(ctx, cacheKey, result, 12*time.Hour) + return result, nil +} diff --git a/internal/jikan/types.go b/internal/jikan/types.go index 4bb640d..7e85827 100644 --- a/internal/jikan/types.go +++ b/internal/jikan/types.go @@ -156,6 +156,18 @@ type TopAnimeResponse struct { Pagination Pagination `json:"pagination"` } +type Episode struct { + MalID int `json:"mal_id"` + Title string `json:"title"` + Filler bool `json:"filler"` + Recap bool `json:"recap"` +} + +type EpisodesResponse struct { + Data []Episode `json:"data"` + Pagination Pagination `json:"pagination"` +} + type JikanRelationEntry struct { MalID int `json:"mal_id"` Type string `json:"type"`