package anime import ( "context" "fmt" "mal/integrations/jikan" "mal/internal/domain" "mal/internal/observability" "mal/internal/server" "net/http" "net/url" "strconv" "strings" "time" "github.com/gin-gonic/gin" ) type producerItem struct { ID int `json:"id"` Name string `json:"name"` } type browseQuery struct { q string animeType string status string orderBy string sort string sfw bool studioID int genres []int page int } func producerQueryParams(c *gin.Context) (string, int, int, error) { q := strings.TrimSpace(c.Query("q")) rawPage := c.DefaultQuery("page", "1") page, err := strconv.Atoi(rawPage) if err != nil { return "", 0, 0, fmt.Errorf("invalid page %q: %w", rawPage, err) } if page < 1 { page = 1 } rawLimit := c.DefaultQuery("limit", "50") limit, err := strconv.Atoi(rawLimit) if err != nil { return "", 0, 0, fmt.Errorf("invalid limit %q: %w", rawLimit, err) } if limit < 1 || limit > 12 { limit = 12 } return q, page, limit, nil } func producerItems(entries []jikan.ProducerListEntry) []producerItem { items := make([]producerItem, 0, len(entries)) for _, producer := range entries { name := jikan.ProducerListEntryName(producer) if producer.MalID <= 0 || name == "" { continue } items = append(items, producerItem{ID: producer.MalID, Name: name}) } return items } func producerHTMLPayload(items []producerItem, hasNextPage bool, page int, q string, limit int) gin.H { return gin.H{ "_fragment": "studio_dropdown_items", "StudioItems": items, "HasNextPage": hasNextPage, "Page": page, "NextPage": page + 1, "Query": q, "Limit": limit, } } func requestWantsHTML(c *gin.Context) bool { return strings.Contains(c.GetHeader("Accept"), "text/html") } func (h *AnimeHandler) HandleProducers(c *gin.Context) { q, page, limit, err := producerQueryParams(c) if err != nil { server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error()) return } res, err := h.svc.GetProducers(c.Request.Context(), q, page, limit) if err != nil { observability.WarnContext(c.Request.Context(), "producers_fetch_failed", "anime", "", map[string]any{ "q": q, "page": page, "limit": limit, }, err, ) if requestWantsHTML(c) { c.HTML(http.StatusOK, "browse.gohtml", producerHTMLPayload([]producerItem{}, false, page, q, limit)) return } server.RespondError( c, http.StatusInternalServerError, "producers_fetch_failed", "anime", "failed to load producers", map[string]any{"q": q, "page": page, "limit": limit}, err, ) return } items := producerItems(res.Items) if requestWantsHTML(c) { c.HTML(http.StatusOK, "browse.gohtml", producerHTMLPayload(items, res.HasNextPage, page, q, limit)) return } c.JSON(http.StatusOK, gin.H{ "items": items, "hasNextPage": res.HasNextPage, "nextPage": page + 1, }) } func parseBrowseQuery(c *gin.Context) (browseQuery, error) { studioID := 0 if raw := strings.TrimSpace(c.Query("studio")); raw != "" { id, err := strconv.Atoi(raw) if err != nil { return browseQuery{}, fmt.Errorf("invalid studio id %q: %w", raw, err) } if id < 0 { return browseQuery{}, fmt.Errorf("invalid studio id %d", id) } studioID = id } genres := make([]int, 0, len(c.QueryArray("genres"))) for _, g := range c.QueryArray("genres") { id, err := strconv.Atoi(g) if err != nil { return browseQuery{}, fmt.Errorf("invalid genre id %q: %w", g, err) } if id > 0 { genres = append(genres, id) } } rawPage := c.DefaultQuery("page", "1") page, err := strconv.Atoi(rawPage) if err != nil { return browseQuery{}, fmt.Errorf("invalid page %q: %w", rawPage, err) } if page < 1 { page = 1 } return browseQuery{ q: c.Query("q"), animeType: c.Query("type"), status: c.Query("status"), orderBy: c.Query("order_by"), sort: c.Query("sort"), sfw: c.Query("sfw") != "false", studioID: studioID, genres: genres, page: page, }, nil } func canonicalBrowseURL(rawURL *url.URL) (string, bool) { if rawURL == nil { return "", false } query := rawURL.Query() if _, exists := query["sfw"]; exists { return "", false } query.Set("sfw", "true") encoded := query.Encode() if encoded == "" { return rawURL.Path, true } return rawURL.Path + "?" + encoded, true } func browseStudioName(ctx context.Context, svc Service, studioID int) string { if studioID <= 0 { return "" } name, err := svc.GetProducerNameByID(ctx, studioID) if err != nil { return "" } return name } func browseTemplateData( q browseQuery, studioName string, genresList []domain.Genre, animes []domain.Anime, user any, watchlistMap map[int64]bool, hasNextPage bool, ) gin.H { return gin.H{ "CurrentPath": "/browse", "Query": q.q, "Type": q.animeType, "Status": q.status, "OrderBy": q.orderBy, "Sort": q.sort, "Genres": q.genres, "Studio": q.studioID, "StudioName": studioName, "SFW": q.sfw, "GenresList": genresList, "Animes": animes, "HasNextPage": hasNextPage, "NextPage": q.page + 1, "User": user, "WatchlistMap": watchlistMap, } } func (h *AnimeHandler) searchBrowse(ctx context.Context, query browseQuery) (jikan.SearchResult, error) { return h.svc.SearchAdvanced( ctx, query.q, query.animeType, query.status, query.orderBy, query.sort, query.genres, query.studioID, query.sfw, query.page, 24, ) } func browseScrollData( query browseQuery, studioName string, animes []domain.Anime, watchlistMap map[int64]bool, hasNextPage bool, ) gin.H { return gin.H{ "_fragment": "anime_card_scroll", "Animes": animes, "NextPage": query.page + 1, "HasNextPage": hasNextPage, "Query": query.q, "Type": query.animeType, "Status": query.status, "OrderBy": query.orderBy, "Sort": query.sort, "Genres": query.genres, "Studio": query.studioID, "StudioName": studioName, "SFW": query.sfw, "WatchlistMap": watchlistMap, } } func (h *AnimeHandler) respondBrowseSearchError(c *gin.Context, query browseQuery, err error) { server.RespondError( c, http.StatusInternalServerError, "browse_search_failed", "anime", "failed to load browse results", map[string]any{ "q": query.q, "type": query.animeType, "status": query.status, "order_by": query.orderBy, "sort": query.sort, "studio": query.studioID, "sfw": query.sfw, "page": query.page, }, err, ) } func (h *AnimeHandler) HandleBrowse(c *gin.Context) { if target, ok := canonicalBrowseURL(c.Request.URL); ok { c.Redirect(http.StatusSeeOther, target) return } query, err := parseBrowseQuery(c) if err != nil { server.RespondHTMLOrJSONError(c, http.StatusBadRequest, err.Error()) return } res, err := h.searchBrowse(c.Request.Context(), query) if err != nil { h.respondBrowseSearchError(c, query, err) return } user := server.CurrentUser(c) userID := server.CurrentUserID(c) animes := wrapAnimes(res.Animes) watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes) studioName := browseStudioName(c.Request.Context(), h.svc, query.studioID) if c.GetHeader("HX-Request") == "true" && query.page > 1 { c.HTML(http.StatusOK, "browse.gohtml", browseScrollData(query, studioName, animes, watchlistMap, res.HasNextPage)) return } genresList, err := h.svc.GetGenres(c.Request.Context()) if err != nil { observability.WarnContext(c.Request.Context(), "genres_fetch_failed", "anime", "", map[string]any{"q": query.q, "type": query.animeType, "status": query.status}, err, ) } browseData := browseTemplateData(query, studioName, genresList, animes, user, watchlistMap, res.HasNextPage) if c.GetHeader("HX-Request") == "true" { browseData["_fragment"] = "browse_content" c.HTML(http.StatusOK, "browse.gohtml", browseData) return } c.HTML(http.StatusOK, "browse.gohtml", browseData) } type quickSearchResult struct { ID int `json:"id"` Title string `json:"title"` Type string `json:"type"` Year int `json:"year"` Image string `json:"image"` InWatchlist bool `json:"in_watchlist"` } func (h *AnimeHandler) HandleQuickSearch(c *gin.Context) { query := c.Query("q") if query == "" { c.JSON(http.StatusOK, []any{}) return } res, err := h.svc.SearchAdvanced(c.Request.Context(), query, "", "", "", "", nil, 0, true, 1, 5) if err != nil { c.JSON(http.StatusOK, []any{}) return } userID := server.CurrentUserID(c) animes := wrapAnimes(res.Animes) watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes) output := make([]quickSearchResult, len(animes)) for i, anime := range animes { output[i] = quickSearchResult{ ID: anime.MalID, Title: anime.DisplayTitle(), Type: anime.Type, Year: anime.Year, Image: anime.Images.Webp.LargeImageURL, InWatchlist: watchlistMap[int64(anime.MalID)], } } c.JSON(http.StatusOK, output) } func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() anime, err := h.svc.GetRandomAnime(ctx) if err != nil { server.RespondError( c, http.StatusInternalServerError, "random_anime_fetch_failed", "anime", "failed to fetch random anime", nil, err, ) return } if anime.MalID == 0 { server.RespondHTMLOrJSONError(c, http.StatusBadGateway, "random anime unavailable") return } inWatchlist := false userID := server.CurrentUserID(c) if userID != "" { watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, []int64{int64(anime.MalID)}) inWatchlist = watchlistMap[int64(anime.MalID)] } c.JSON(http.StatusOK, gin.H{ "data": anime, "in_watchlist": inWatchlist, }) }