package anime import ( "context" "fmt" "mal/integrations/jikan" "mal/internal/domain" "mal/internal/observability" "mal/internal/server" "net/http" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" ) const ( animeSectionTimeout = 12 * time.Second watchOrderTimeout = 15 * time.Second audioLookupTimeout = 8 * time.Second ) type AnimeHandler struct { svc Service watchlistSvc domain.WatchlistService episodeSvc domain.EpisodeService scheduleCacheMu sync.Mutex scheduleCache map[string]cachedWeekSchedule } type Service interface { domain.AnimeCatalogService domain.AnimeDiscoverService domain.AnimeSearchService domain.AnimeDetailsService WarmDetailSections(id int) } func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService) *AnimeHandler { return &AnimeHandler{ svc: svc, watchlistSvc: watchlistSvc, episodeSvc: episodeSvc, scheduleCache: map[string]cachedWeekSchedule{}, } } func (h *AnimeHandler) watchlistMapForAnimes(ctx context.Context, userID string, animes []domain.Anime) map[int64]bool { animeIDs := make([]int64, 0, len(animes)) for _, anime := range animes { if anime.MalID > 0 { animeIDs = append(animeIDs, int64(anime.MalID)) } } return h.watchlistMapForIDs(ctx, userID, animeIDs) } func (h *AnimeHandler) watchlistMapForIDs(ctx context.Context, userID string, animeIDs []int64) map[int64]bool { if userID == "" || len(animeIDs) == 0 { return map[int64]bool{} } watchlistMap, err := h.watchlistSvc.GetWatchlistMap(ctx, userID, animeIDs) if err != nil { return map[int64]bool{} } return watchlistMap } func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string { hasKnownSub := false for _, episode := range episodes { if episode.HasDub { return "Dub available" } if episode.HasSub || episode.SubOnly { hasKnownSub = true } } if hasKnownSub { return "Subtitled only" } return "" } func (h *AnimeHandler) animeAudioAvailability(ctx context.Context, anime domain.Anime) string { if h.episodeSvc == nil { return "" } audioCtx, cancel := context.WithTimeout(ctx, audioLookupTimeout) defer cancel() episodeList, err := h.episodeSvc.GetCanonicalEpisodes(audioCtx, anime, true) if err != nil { observability.Warn( "anime_audio_availability_fetch_failed", "anime", "", map[string]any{ "anime_id": anime.MalID, }, err, ) return "" } if episodeList.Source != "AllAnime" { return "" } return animeAudioAvailabilityLabel(episodeList.Episodes) } func (h *AnimeHandler) Register(r *gin.Engine) { r.GET("/", h.HandleCatalog) r.GET("/api/catalog/airing", h.HandleCatalogAiring) r.GET("/api/catalog/popular", h.HandleCatalogPopular) r.GET("/api/catalog/continue", h.HandleCatalogContinue) r.GET("/api/catalog/top-pick", h.HandleCatalogTopPickForYou) r.GET("/discover", h.HandleDiscover) r.GET("/api/discover/trending", h.HandleDiscoverTrending) r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming) r.GET("/api/discover/top", h.HandleDiscoverTop) r.GET("/schedule", h.HandleSchedule) r.GET("/api/schedule", h.HandleScheduleSection) r.GET("/browse", h.HandleBrowse) r.GET("/anime/:id", h.HandleAnimeDetails) r.GET("/anime/:id/reviews", h.HandleAnimeReviews) r.GET("/api/watch-order", h.HandleHTMLWatchOrder) r.GET("/api/search-quick", h.HandleQuickSearch) r.GET("/api/command-palette", h.HandleCommandPalette) r.GET("/api/jikan/random/anime", h.HandleRandomAnime) r.GET("/api/jikan/producers", h.HandleProducers) } func (h *AnimeHandler) HandleProducers(c *gin.Context) { q := strings.TrimSpace(c.Query("q")) page, err := strconv.Atoi(c.DefaultQuery("page", "1")) if err != nil { server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page") return } if page < 1 { page = 1 } limit, err := strconv.Atoi(c.DefaultQuery("limit", "50")) if err != nil { server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid limit") return } if limit < 1 { limit = 12 } if limit > 12 { limit = 12 } res, err := h.svc.GetProducers(c.Request.Context(), q, page, limit) if err != nil { observability.Warn( "producers_fetch_failed", "anime", "", map[string]any{ "q": q, "page": page, "limit": limit, }, err, ) if strings.Contains(c.GetHeader("Accept"), "text/html") { c.HTML(http.StatusOK, "browse.gohtml", gin.H{ "_fragment": "studio_dropdown_items", "StudioItems": []any{}, "HasNextPage": false, "Page": page, "NextPage": page + 1, "Query": q, "Limit": 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 } type item struct { ID int `json:"id"` Name string `json:"name"` } items := make([]item, 0, len(res.Items)) for _, p := range res.Items { name := jikan.ProducerListEntryName(p) if p.MalID <= 0 || name == "" { continue } items = append(items, item{ID: p.MalID, Name: name}) } if strings.Contains(c.GetHeader("Accept"), "text/html") { c.HTML(http.StatusOK, "browse.gohtml", gin.H{ "_fragment": "studio_dropdown_items", "StudioItems": items, "HasNextPage": res.HasNextPage, "Page": page, "NextPage": page + 1, "Query": q, "Limit": limit, }) return } c.JSON(http.StatusOK, gin.H{ "items": items, "hasNextPage": res.HasNextPage, "nextPage": page + 1, }) } func (h *AnimeHandler) HandleCatalog(c *gin.Context) { user := server.CurrentUser(c) c.HTML(http.StatusOK, "index.gohtml", gin.H{ "CurrentPath": "/", "User": user, "WatchlistMap": map[int64]bool{}, }) } func (h *AnimeHandler) HandleCatalogAiring(c *gin.Context) { h.renderCatalogSection(c, "Airing") } func (h *AnimeHandler) HandleCatalogPopular(c *gin.Context) { h.renderCatalogSection(c, "Popular") } func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) { h.renderCatalogSection(c, "Continue") } func (h *AnimeHandler) HandleCatalogTopPickForYou(c *gin.Context) { userID := server.CurrentUserID(c) data, err := h.svc.GetTopPickForYou(c.Request.Context(), userID) if err != nil { observability.Warn( "top_pick_for_you_fetch_failed", "anime", "", map[string]any{ "user_id": userID, }, err, ) c.AbortWithStatus(http.StatusInternalServerError) return } watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes) data.Section = "TopPickForYou" data.Fragment = "top_pick_for_you_section" data.WatchlistMap = watchlistMap c.HTML(http.StatusOK, "index.gohtml", data) } func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) { userID := server.CurrentUserID(c) data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section) if err != nil { h.abortSectionFetch(c, "catalog_section_fetch_failed", userID, section, err) return } watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes) data.Section = section data.Fragment = "catalog_section" data.WatchlistMap = watchlistMap c.HTML(http.StatusOK, "index.gohtml", data) } func (h *AnimeHandler) HandleDiscover(c *gin.Context) { user := server.CurrentUser(c) c.HTML(http.StatusOK, "discover.gohtml", gin.H{ "CurrentPath": "/discover", "User": user, }) } func (h *AnimeHandler) HandleDiscoverTrending(c *gin.Context) { h.renderDiscoverSection(c, "Trending") } func (h *AnimeHandler) HandleDiscoverUpcoming(c *gin.Context) { h.renderDiscoverSection(c, "Upcoming") } func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) { h.renderDiscoverSection(c, "Top") } func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) { userID := server.CurrentUserID(c) data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section) if err != nil { h.abortSectionFetch(c, "discover_section_fetch_failed", userID, section, err) return } watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes) data.Section = section data.Fragment = "discover_section" data.WatchlistMap = watchlistMap c.HTML(http.StatusOK, "discover.gohtml", data) } func (h *AnimeHandler) abortSectionFetch(c *gin.Context, event string, userID string, section string, err error) { observability.Warn( event, "anime", "", map[string]any{ "section": section, "user_id": userID, }, err, ) c.AbortWithStatus(http.StatusInternalServerError) } func (h *AnimeHandler) HandleSchedule(c *gin.Context) { user := server.CurrentUser(c) year, week := parseYearWeek(c) c.HTML(http.StatusOK, "schedule.gohtml", gin.H{ "CurrentPath": "/schedule", "User": user, "ScheduleYear": year, "ScheduleWeek": week, }) } func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) { year, week := parseYearWeek(c) timezone := scheduleTimezone(c) schedule, err := h.getCachedAnimeScheduleWeek(c.Request.Context(), year, week, timezone) if err != nil { prevYear, prevWeek := adjacentISOWeek(year, week, -1) nextYear, nextWeek := adjacentISOWeek(year, week, 1) observability.Warn( "animeschedule_fetch_failed", "anime", "", map[string]any{ "year": year, "week": week, "timezone": timezone, }, err, ) c.HTML(http.StatusOK, "schedule.gohtml", gin.H{ "_fragment": "schedule_section_scraped", "ScheduleDays": []any{}, "ScheduleYear": year, "ScheduleWeek": week, "PrevYear": prevYear, "PrevWeek": prevWeek, "NextYear": nextYear, "NextWeek": nextWeek, "ScheduleError": true, }) return } days := buildScheduleDays(schedule, schedule.Year, schedule.Week) prevYear, prevWeek := adjacentISOWeek(schedule.Year, schedule.Week, -1) nextYear, nextWeek := adjacentISOWeek(schedule.Year, schedule.Week, 1) c.HTML(http.StatusOK, "schedule.gohtml", gin.H{ "_fragment": "schedule_section_scraped", "ScheduleDays": days, "ScheduleYear": schedule.Year, "ScheduleWeek": schedule.Week, "PrevYear": prevYear, "PrevWeek": prevWeek, "NextYear": nextYear, "NextWeek": nextWeek, }) } func (h *AnimeHandler) HandleBrowse(c *gin.Context) { 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 := 0 if raw := strings.TrimSpace(c.Query("studio")); raw != "" { id, err := strconv.Atoi(raw) if err != nil || id < 0 { server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid studio id") return } studioID = id } var genres []int for _, g := range c.QueryArray("genres") { id, err := strconv.Atoi(g) if err != nil { server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid genre id") return } if id > 0 { genres = append(genres, id) } } page, err := strconv.Atoi(c.DefaultQuery("page", "1")) if err != nil { server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page") return } if page < 1 { page = 1 } res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, studioID, sfw, page, 24) if err != nil { server.RespondError( c, http.StatusInternalServerError, "browse_search_failed", "anime", "failed to load browse results", map[string]any{ "q": q, "type": animeType, "status": status, "order_by": orderBy, "sort": sort, "studio": studioID, "sfw": sfw, "page": page, }, err, ) return } user := server.CurrentUser(c) userID := server.CurrentUserID(c) animes := wrapAnimes(res.Animes) watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes) studioName := "" if studioID > 0 { name, err := h.svc.GetProducerNameByID(c.Request.Context(), studioID) if err == nil { studioName = name } } if c.GetHeader("HX-Request") == "true" && page > 1 { c.HTML(http.StatusOK, "browse.gohtml", gin.H{ "_fragment": "anime_card_scroll", "Animes": animes, "NextPage": page + 1, "HasNextPage": res.HasNextPage, "Query": q, "Type": animeType, "Status": status, "OrderBy": orderBy, "Sort": sort, "Genres": genres, "Studio": studioID, "StudioName": studioName, "SFW": sfw, "WatchlistMap": watchlistMap, }) return } genresList, _ := h.svc.GetGenres(c.Request.Context()) browseData := gin.H{ "CurrentPath": "/browse", "Query": q, "Type": animeType, "Status": status, "OrderBy": orderBy, "Sort": sort, "Genres": genres, "Studio": studioID, "StudioName": studioName, "SFW": sfw, "GenresList": genresList, "Animes": animes, "HasNextPage": res.HasNextPage, "NextPage": page + 1, "User": user, "WatchlistMap": watchlistMap, } 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) } func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil || id <= 0 { server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id") return } section := c.Query("section") if section != "" && c.GetHeader("HX-Request") == "true" { sectionCtx, cancel := context.WithTimeout(c.Request.Context(), animeSectionTimeout) defer cancel() var data any var tplName string var err error switch section { case "characters": data, err = h.svc.GetCharacters(sectionCtx, id) tplName = "anime_characters" case "recommendations": data, err = h.svc.GetRecommendations(sectionCtx, id) tplName = "anime_recommendations" case "statistics": data, err = h.svc.GetStatistics(sectionCtx, id) tplName = "anime_statistics" case "themes": data, err = h.svc.GetThemes(sectionCtx, id) tplName = "anime_themes" } if err != nil { observability.Warn( "anime_section_fetch_failed", "anime", "", map[string]any{ "section": section, "anime_id": id, }, err, ) if section == "recommendations" { c.HTML(http.StatusOK, "anime.gohtml", gin.H{ "_fragment": "anime_recommendations_loading", "AnimeID": id, }) return } c.Status(http.StatusNoContent) return } c.HTML(http.StatusOK, "anime.gohtml", gin.H{ "_fragment": tplName, "Items": data, }) return } anime, err := h.svc.GetAnimeByID(c.Request.Context(), id) if err != nil { c.Status(http.StatusNotFound) return } h.svc.WarmDetailSections(id) user := server.CurrentUser(c) status := "" var watchlistIDs []int64 ep := 0 var cwSeconds float64 if user != nil { entry, err := h.watchlistSvc.GetWatchListEntry(c.Request.Context(), user.ID, int64(id)) if err == nil { status = entry.Status watchlistIDs = []int64{entry.AnimeID} } cwEntry, err := h.watchlistSvc.GetContinueWatchingEntry(c.Request.Context(), user.ID, int64(id)) if err == nil && cwEntry.CurrentEpisode.Valid { ep = int(cwEntry.CurrentEpisode.Int64) cwSeconds = cwEntry.CurrentTimeSeconds } } audioAvailability := h.animeAudioAvailability(c.Request.Context(), anime) c.HTML(http.StatusOK, "anime.gohtml", gin.H{ "Anime": anime, "AudioAvailability": audioAvailability, "CurrentPath": fmt.Sprintf("/anime/%d", id), "User": user, "Status": status, "WatchlistIDs": watchlistIDs, "ContinueWatchingEp": ep, "ContinueWatchingTime": cwSeconds, }) } func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) { id, err := strconv.Atoi(c.Query("animeId")) if err != nil || id <= 0 { server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id") return } userID := server.CurrentUserID(c) relationsCtx, cancel := context.WithTimeout(c.Request.Context(), watchOrderTimeout) defer cancel() relations, err := h.svc.GetRelations(relationsCtx, id) if err != nil { observability.Warn( "relations_fetch_failed", "anime", "", map[string]any{ "anime_id": id, }, err, ) c.HTML(http.StatusOK, "anime.gohtml", gin.H{ "_fragment": "watch_order_loading", "AnimeID": id, }) return } relationAnimeIDs := make([]int64, 0, len(relations)) for _, relation := range relations { if relation.Anime.MalID > 0 { relationAnimeIDs = append(relationAnimeIDs, int64(relation.Anime.MalID)) } } watchlistMap := h.watchlistMapForIDs(c.Request.Context(), userID, relationAnimeIDs) c.HTML(http.StatusOK, "anime.gohtml", gin.H{ "_fragment": "watch_order", "Relations": relations, "AnimeID": id, "WatchlistMap": watchlistMap, }) } 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) 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"` } 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.ImageURL(), 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, }) } func (h *AnimeHandler) HandleAnimeReviews(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil || id <= 0 { server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id") return } page, err := strconv.Atoi(c.DefaultQuery("page", "1")) if err != nil { server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid page") return } if page < 1 { page = 1 } reviews, hasNextPage, err := h.svc.GetReviews(c.Request.Context(), id, page) if err != nil { server.RespondError( c, http.StatusInternalServerError, "anime_reviews_fetch_failed", "anime", "failed to load reviews", map[string]any{"anime_id": id, "page": page}, err, ) return } user := server.CurrentUser(c) if c.GetHeader("HX-Request") == "true" && page > 1 { c.HTML(http.StatusOK, "reviews.gohtml", gin.H{ "_fragment": "review_cards", "Reviews": reviews, "NextPage": page + 1, "HasNextPage": hasNextPage, "AnimeID": id, }) return } c.HTML(http.StatusOK, "reviews.gohtml", gin.H{ "CurrentPath": fmt.Sprintf("/anime/%d/reviews", id), "Reviews": reviews, "NextPage": page + 1, "HasNextPage": hasNextPage, "AnimeID": id, "User": user, }) }