432 lines
9.8 KiB
Go
432 lines
9.8 KiB
Go
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,
|
|
})
|
|
}
|