diff --git a/internal/features/anime/handler.go b/internal/features/anime/handler.go index b785ff4..9989c1a 100644 --- a/internal/features/anime/handler.go +++ b/internal/features/anime/handler.go @@ -6,6 +6,7 @@ import ( "log" "net/http" "strconv" + "strings" "mal/internal/database" "mal/internal/jikan" @@ -29,6 +30,23 @@ type Handler struct { svc *Service } +type quickSearchResult struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Image string `json:"image"` +} + +func renderNotFoundPage(r *http.Request, w http.ResponseWriter) { + w.WriteHeader(http.StatusNotFound) + templates.NotFoundPage().Render(r.Context(), w) +} + +func writeInlineLoadError(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte(`

` + message + `

`)) +} + func parsePageParam(r *http.Request) int { page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { @@ -53,8 +71,7 @@ func NewHandler(svc *Service) *Handler { func (h *Handler) HandleCatalog(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { - w.WriteHeader(http.StatusNotFound) - templates.NotFoundPage().Render(r.Context(), w) + renderNotFoundPage(r, w) return } templates.Catalog().Render(r.Context(), w) @@ -110,7 +127,7 @@ func (h *Handler) HandleAPICatalog(w http.ResponseWriter, r *http.Request) { } if fallbackPlaceholder { - templates.CatalogPlaceholderItems(24).Render(r.Context(), w) + templates.CatalogPlaceholderItems(jikan.ListPageSize).Render(r.Context(), w) return } @@ -123,8 +140,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Path[len("/anime/"):] id, err := strconv.Atoi(idStr) if err != nil || id <= 0 { - w.WriteHeader(http.StatusNotFound) - templates.NotFoundPage().Render(r.Context(), w) + renderNotFoundPage(r, w) return } @@ -138,8 +154,7 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) { } if jikan.IsNotFoundError(err) { - w.WriteHeader(http.StatusNotFound) - templates.NotFoundPage().Render(r.Context(), w) + renderNotFoundPage(r, w) return } @@ -151,55 +166,27 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) { templates.AnimeDetails(anime, currentStatus).Render(r.Context(), w) } -func (h *Handler) HandleAPIAnimeRelations(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path[len("/api/anime/"):] - idStr := "" - for i, c := range path { - if c == '/' { - idStr = path[:i] - break - } - } - - id, _ := strconv.Atoi(idStr) - if id <= 0 { - http.Error(w, "invalid id", http.StatusBadRequest) - return - } - - relations, err := h.svc.GetRelations(r.Context(), id) - if err != nil { - log.Printf("failed to get relations for anime %d: %v", id, err) - http.Error(w, "Failed to load relations", http.StatusInternalServerError) - return - } - templates.AnimeRelationsList(relations).Render(r.Context(), w) -} - -// HandleAPIAnime routes anime API requests func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) { path := r.URL.Path[len("/api/anime/"):] - // Parse: {id}/relations or {id}/recommendations - parts := splitPath(path) - if len(parts) < 2 { + idPart, section, ok := strings.Cut(path, "/") + if !ok || section == "" { http.Error(w, "invalid path", http.StatusBadRequest) return } - id, err := strconv.Atoi(parts[0]) + id, err := strconv.Atoi(idPart) if err != nil || id <= 0 { http.Error(w, "invalid id", http.StatusBadRequest) return } - switch parts[1] { + switch section { case "relations": relations, err := h.svc.GetRelations(r.Context(), id) if err != nil { log.Printf("relations error for %d: %v", id, err) - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(`

Failed to load relations.

`)) + writeInlineLoadError(w, "Failed to load relations.") return } templates.AnimeRelationsList(relations).Render(r.Context(), w) @@ -207,69 +194,40 @@ func (h *Handler) HandleAPIAnime(w http.ResponseWriter, r *http.Request) { recs, err := h.svc.GetRecommendations(r.Context(), id, 12) if err != nil { log.Printf("recommendations error for %d: %v", id, err) - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(`

Failed to load recommendations.

`)) + writeInlineLoadError(w, "Failed to load recommendations.") return } templates.AnimeRecommendations(recs).Render(r.Context(), w) default: - w.WriteHeader(http.StatusNotFound) - templates.NotFoundPage().Render(r.Context(), w) + renderNotFoundPage(r, w) } } -func splitPath(path string) []string { - var parts []string - var current string - for _, c := range path { - if c == '/' { - if current != "" { - parts = append(parts, current) - current = "" - } - } else { - current += string(c) - } - } - if current != "" { - parts = append(parts, current) - } - return parts -} - func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + query := r.URL.Query().Get("q") if query == "" { - w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode([]any{}) + json.NewEncoder(w).Encode([]quickSearchResult{}) return } res, err := h.svc.Search(r.Context(), query, 1) if err != nil { log.Printf("quick search error: %v", err) - w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) return } - // Limit to 5 results results := res.Animes if len(results) > 5 { results = results[:5] } - type SearchResult struct { - ID int `json:"id"` - Title string `json:"title"` - Type string `json:"type"` - Image string `json:"image"` - } - - output := make([]SearchResult, len(results)) + output := make([]quickSearchResult, len(results)) for i, anime := range results { - output[i] = SearchResult{ + output[i] = quickSearchResult{ ID: anime.MalID, Title: anime.DisplayTitle(), Type: anime.Type, @@ -277,7 +235,6 @@ func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) { } } - w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(output) } diff --git a/internal/jikan/constants.go b/internal/jikan/constants.go new file mode 100644 index 0000000..6b8bbc8 --- /dev/null +++ b/internal/jikan/constants.go @@ -0,0 +1,7 @@ +package jikan + +import "time" + +const ListPageSize = 24 + +const shortCacheTTL = time.Hour diff --git a/internal/jikan/search.go b/internal/jikan/search.go index 667db8f..b8afcaa 100644 --- a/internal/jikan/search.go +++ b/internal/jikan/search.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/url" - "time" ) func (c *Client) Search(ctx context.Context, query string, page int) (SearchResult, error) { @@ -15,7 +14,7 @@ func (c *Client) Search(ctx context.Context, query string, page int) (SearchResu page = 1 } - cacheKey := fmt.Sprintf("search:limit24:%s:%d", query, page) + cacheKey := fmt.Sprintf("search:limit%d:%s:%d", ListPageSize, query, page) var cached SearchResult if c.getCache(ctx, cacheKey, &cached) { return cached, nil @@ -25,7 +24,7 @@ func (c *Client) Search(ctx context.Context, query string, page int) (SearchResu hasStale := c.getStaleCache(ctx, cacheKey, &stale) var result SearchResponse - reqURL := fmt.Sprintf("%s/anime?q=%s&limit=24&page=%d", c.baseURL, url.QueryEscape(query), page) + reqURL := fmt.Sprintf("%s/anime?q=%s&limit=%d&page=%d", c.baseURL, url.QueryEscape(query), ListPageSize, page) if err := c.fetchWithRetry(ctx, reqURL, &result); err != nil { if hasStale { @@ -40,7 +39,7 @@ func (c *Client) Search(ctx context.Context, query string, page int) (SearchResu HasNextPage: result.Pagination.HasNextPage, } - c.setCache(ctx, cacheKey, res, time.Hour*1) + c.setCache(ctx, cacheKey, res, shortCacheTTL) return res, nil } @@ -48,7 +47,7 @@ func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, err if page < 1 { page = 1 } - cacheKey := fmt.Sprintf("top:limit24:%d", page) + cacheKey := fmt.Sprintf("top:limit%d:%d", ListPageSize, page) var cached TopAnimeResult if c.getCache(ctx, cacheKey, &cached) { return cached, nil @@ -58,7 +57,7 @@ func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, err hasStale := c.getStaleCache(ctx, cacheKey, &stale) var result TopAnimeResponse - reqURL := fmt.Sprintf("%s/top/anime?filter=bypopularity&limit=24&page=%d", c.baseURL, page) + reqURL := fmt.Sprintf("%s/top/anime?filter=bypopularity&limit=%d&page=%d", c.baseURL, ListPageSize, page) if err := c.fetchWithRetry(ctx, reqURL, &result); err != nil { if hasStale { @@ -73,6 +72,6 @@ func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, err HasNextPage: result.Pagination.HasNextPage, } - c.setCache(ctx, cacheKey, res, time.Hour*1) + c.setCache(ctx, cacheKey, res, shortCacheTTL) return res, nil } diff --git a/internal/jikan/seasons.go b/internal/jikan/seasons.go index 6f6003a..2a8ee66 100644 --- a/internal/jikan/seasons.go +++ b/internal/jikan/seasons.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "strings" - "time" ) type ScheduleResult struct { @@ -14,7 +13,7 @@ type ScheduleResult struct { func (c *Client) GetSchedule(ctx context.Context, day string) (ScheduleResult, error) { day = strings.ToLower(day) - cacheKey := fmt.Sprintf("schedule_limit24_%s", day) + cacheKey := fmt.Sprintf("schedule_limit%d_%s", ListPageSize, day) var cached ScheduleResult if c.getCache(ctx, cacheKey, &cached) { @@ -25,7 +24,7 @@ func (c *Client) GetSchedule(ctx context.Context, day string) (ScheduleResult, e hasStale := c.getStaleCache(ctx, cacheKey, &stale) var result TopAnimeResponse - reqURL := fmt.Sprintf("%s/schedules?filter=%s&sfw=true&limit=24", c.baseURL, day) + reqURL := fmt.Sprintf("%s/schedules?filter=%s&sfw=true&limit=%d", c.baseURL, day, ListPageSize) if err := c.fetchWithRetry(ctx, reqURL, &result); err != nil { if hasStale { return stale, nil @@ -39,7 +38,7 @@ func (c *Client) GetSchedule(ctx context.Context, day string) (ScheduleResult, e HasNextPage: result.Pagination.HasNextPage, } - c.setCache(ctx, cacheKey, res, time.Hour*1) + c.setCache(ctx, cacheKey, res, shortCacheTTL) return res, nil } @@ -62,7 +61,7 @@ func (c *Client) GetSeasonsNow(ctx context.Context, page int) (TopAnimeResult, e if page < 1 { page = 1 } - cacheKey := fmt.Sprintf("seasons_now_limit24:%d", page) + cacheKey := fmt.Sprintf("seasons_now_limit%d:%d", ListPageSize, page) var cached TopAnimeResult if c.getCache(ctx, cacheKey, &cached) { return cached, nil @@ -72,7 +71,7 @@ func (c *Client) GetSeasonsNow(ctx context.Context, page int) (TopAnimeResult, e hasStale := c.getStaleCache(ctx, cacheKey, &stale) var result TopAnimeResponse - reqURL := fmt.Sprintf("%s/seasons/now?limit=24&page=%d", c.baseURL, page) + reqURL := fmt.Sprintf("%s/seasons/now?limit=%d&page=%d", c.baseURL, ListPageSize, page) if err := c.fetchWithRetry(ctx, reqURL, &result); err != nil { if hasStale { return stale, nil @@ -86,7 +85,7 @@ func (c *Client) GetSeasonsNow(ctx context.Context, page int) (TopAnimeResult, e HasNextPage: result.Pagination.HasNextPage, } - c.setCache(ctx, cacheKey, res, time.Hour*1) + c.setCache(ctx, cacheKey, res, shortCacheTTL) return res, nil } @@ -94,7 +93,7 @@ func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResu if page < 1 { page = 1 } - cacheKey := fmt.Sprintf("seasons_upcoming_limit24:%d", page) + cacheKey := fmt.Sprintf("seasons_upcoming_limit%d:%d", ListPageSize, page) var cached TopAnimeResult if c.getCache(ctx, cacheKey, &cached) { return cached, nil @@ -104,7 +103,7 @@ func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResu hasStale := c.getStaleCache(ctx, cacheKey, &stale) var result TopAnimeResponse - reqURL := fmt.Sprintf("%s/seasons/upcoming?limit=24&page=%d", c.baseURL, page) + reqURL := fmt.Sprintf("%s/seasons/upcoming?limit=%d&page=%d", c.baseURL, ListPageSize, page) if err := c.fetchWithRetry(ctx, reqURL, &result); err != nil { if hasStale { return stale, nil @@ -118,6 +117,6 @@ func (c *Client) GetSeasonsUpcoming(ctx context.Context, page int) (TopAnimeResu HasNextPage: result.Pagination.HasNextPage, } - c.setCache(ctx, cacheKey, res, time.Hour*1) + c.setCache(ctx, cacheKey, res, shortCacheTTL) return res, nil }