package anime import ( "context" "encoding/json" "errors" "html" "log" "net/http" "strconv" "strings" "time" "mal/integrations/jikan" ctxpkg "mal/internal/context" "mal/internal/db" "mal/internal/middleware" "mal/templates" "golang.org/x/sync/errgroup" ) type Handler struct { jikanClient *jikan.Client db database.Querier } 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) if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "not_found.gohtml", map[string]any{ "CurrentPath": r.URL.Path, }); err != nil { log.Printf("render error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } func writeInlineLoadError(w http.ResponseWriter, message string) { w.Header().Set("Content-Type", "text/html") _, _ = w.Write([]byte(`

` + html.EscapeString(message) + `

`)) } func parsePageParam(r *http.Request) int { page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page < 1 { return 1 } return page } func NewHandler(jikanClient *jikan.Client, db database.Querier) *Handler { return &Handler{jikanClient: jikanClient, db: db} } func (h *Handler) HandleCatalog(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { renderNotFoundPage(r, w) return } var ( animes jikan.TopAnimeResult currentlyAiring jikan.TopAnimeResult cw []database.GetContinueWatchingEntriesRow watchlist []database.GetUserWatchListRow ) g, gCtx := errgroup.WithContext(r.Context()) g.Go(func() error { var err error animes, err = h.jikanClient.GetTopAnime(gCtx, 1) return err }) g.Go(func() error { var err error currentlyAiring, err = h.jikanClient.GetSeasonsNow(gCtx, 1) if err != nil { log.Printf("seasons now error: %v", err) return nil // non-fatal } return nil }) user := middleware.GetUser(r.Context()) if user != nil { g.Go(func() error { var err error cw, err = h.db.GetContinueWatchingEntries(gCtx, user.ID) return err }) g.Go(func() error { var err error watchlist, err = h.db.GetUserWatchList(gCtx, user.ID) return err }) } if err := g.Wait(); err != nil { log.Printf("catalog fetch error: %v", err) http.Error(w, "Failed to fetch catalog data", http.StatusInternalServerError) return } if len(animes.Animes) > 6 { animes.Animes = animes.Animes[:6] } if len(currentlyAiring.Animes) > 6 { currentlyAiring.Animes = currentlyAiring.Animes[:6] } watchlistMap := make(map[int64]bool) watchlistIDs := make([]int64, len(watchlist)) for i, entry := range watchlist { watchlistMap[entry.AnimeID] = true watchlistIDs[i] = entry.AnimeID } if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "index.gohtml", map[string]any{ "MostPopular": animes.Animes, "CurrentlyAiring": currentlyAiring.Animes, "ContinueWatching": cw, "User": user, "CurrentPath": r.URL.Path, "WatchlistMap": watchlistMap, "WatchlistIDs": watchlistIDs, }); err != nil { log.Printf("render error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } func (h *Handler) HandleBrowse(w http.ResponseWriter, r *http.Request) { user := middleware.GetUser(r.Context()) q := r.URL.Query().Get("q") animeType := r.URL.Query().Get("type") status := r.URL.Query().Get("status") orderBy := r.URL.Query().Get("order_by") sort := r.URL.Query().Get("sort") var genres []int for _, g := range r.URL.Query()["genres"] { id, err := strconv.Atoi(g) if err == nil { genres = append(genres, id) } } pageStr := r.URL.Query().Get("page") page, _ := strconv.Atoi(pageStr) if page < 1 { page = 1 } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() res, err := h.jikanClient.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, page, 24) if err != nil { if errors.Is(err, context.Canceled) { return } log.Printf("browse error: %v", err) } if r.Header.Get("HX-Request") == "true" { watchlistMap := make(map[int]bool) if user != nil { watchlist, _ := h.db.GetUserWatchList(ctx, user.ID) for _, entry := range watchlist { watchlistMap[int(entry.AnimeID)] = true } } w.Header().Set("Content-Type", "text/html") err := templates.GetRenderer().ExecuteFragment(ctx, w, "browse.gohtml", "anime_card_scroll", map[string]any{ "Animes": res.Animes, "NextPage": page + 1, "HasNextPage": res.HasNextPage, "Query": q, "Type": animeType, "Status": status, "OrderBy": orderBy, "Sort": sort, "Genres": genres, "WatchlistMap": watchlistMap, }) if err != nil { if !errors.Is(err, context.Canceled) { log.Printf("fragment render error: %v", err) } } return } genresList, err := h.jikanClient.GetAnimeGenres(ctx) if err != nil { if !errors.Is(err, context.Canceled) { log.Printf("genres error: %v", err) } } watchlistMap := make(map[int]bool) var watchlistIDs []int64 if user != nil { watchlist, _ := h.db.GetUserWatchList(ctx, user.ID) watchlistIDs = make([]int64, len(watchlist)) for i, entry := range watchlist { watchlistMap[int(entry.AnimeID)] = true watchlistIDs[i] = entry.AnimeID } } if err := templates.GetRenderer().ExecuteTemplate(ctx, w, "browse.gohtml", map[string]any{ "User": user, "CurrentPath": r.URL.Path, "Query": q, "Type": animeType, "Status": status, "OrderBy": orderBy, "Sort": sort, "Genres": genres, "GenresList": genresList, "Animes": res.Animes, "HasNextPage": res.HasNextPage, "NextPage": page + 1, "WatchlistMap": watchlistMap, "WatchlistIDs": watchlistIDs, }); err != nil { if !errors.Is(err, context.Canceled) { log.Printf("render error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } } func (h *Handler) HandleSearch(w http.ResponseWriter, r *http.Request) { renderNotFoundPage(r, w) } func (h *Handler) HandleAPISearch(w http.ResponseWriter, r *http.Request) { http.Error(w, "Not implemented yet", http.StatusNotImplemented) } func (h *Handler) HandleAPICatalog(w http.ResponseWriter, r *http.Request) { http.Error(w, "Not implemented yet", http.StatusNotImplemented) } func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) { idStr := strings.TrimPrefix(r.URL.Path, "/anime/") idStr = strings.TrimSuffix(idStr, "/") id, err := strconv.Atoi(idStr) if err != nil { renderNotFoundPage(r, w) return } var ( anime jikan.Anime characters []jikan.CharacterEntry recommendations []jikan.RecommendationEntry watchlist []database.GetUserWatchListRow status string ) g, gCtx := errgroup.WithContext(r.Context()) g.Go(func() error { var err error anime, err = h.jikanClient.GetAnimeByID(gCtx, id) return err }) g.Go(func() error { var err error characters, err = h.jikanClient.GetAnimeCharacters(gCtx, id) if err != nil { log.Printf("characters fetch error: %v", err) } return nil }) g.Go(func() error { var err error recommendations, err = h.jikanClient.GetAnimeRecommendations(gCtx, id) if err != nil { log.Printf("recommendations fetch error: %v", err) } return nil }) user := middleware.GetUser(r.Context()) if user != nil { g.Go(func() error { entry, err := h.db.GetWatchListEntry(gCtx, database.GetWatchListEntryParams{ UserID: user.ID, AnimeID: int64(id), }) if err == nil { status = entry.Status } return nil }) g.Go(func() error { var err error watchlist, err = h.db.GetUserWatchList(gCtx, user.ID) return err }) } if err := g.Wait(); err != nil { log.Printf("anime details fetch error: %v", err) renderNotFoundPage(r, w) return } watchlistIDs := make([]int64, len(watchlist)) for i, e := range watchlist { watchlistIDs[i] = e.AnimeID } if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "anime.gohtml", map[string]any{ "Anime": anime, "Characters": characters, "Recommendations": recommendations, "User": user, "Status": status, "CurrentPath": r.URL.Path, "WatchlistIDs": watchlistIDs, }); err != nil { log.Printf("render error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } func (h *Handler) HandleHTMLWatchOrder(w http.ResponseWriter, r *http.Request) { animeIdStr := r.URL.Query().Get("animeId") id, err := strconv.Atoi(animeIdStr) if err != nil { http.Error(w, `
Invalid anime ID.
`, http.StatusBadRequest) return } relations, err := h.jikanClient.GetFullRelations(r.Context(), id) if err != nil { log.Printf("watch order error: %v", err) http.Error(w, `
Failed to load watch order.
`, http.StatusInternalServerError) return } user := middleware.GetUser(r.Context()) watchlistMap := make(map[int64]bool) if user != nil { watchlist, _ := h.db.GetUserWatchList(r.Context(), user.ID) for _, entry := range watchlist { watchlistMap[entry.AnimeID] = true } } if err := templates.GetRenderer().ExecuteFragment(r.Context(), w, "anime.gohtml", "watch_order", map[string]any{ "Relations": relations, "AnimeID": id, "WatchlistMap": watchlistMap, }); err != nil { log.Printf("render error: %v", err) } } func (h *Handler) HandleAPIEpisodes(w http.ResponseWriter, r *http.Request) { http.Error(w, "Not implemented yet", http.StatusNotImplemented) } 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.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode([]quickSearchResult{}) return } res, err := h.jikanClient.SearchAdvanced(r.Context(), query, "", "", "", "", nil, 1, 5) if err != nil { log.Printf("quick search error: %v", err) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode([]quickSearchResult{}) return } output := make([]quickSearchResult, len(res.Animes)) for i, anime := range res.Animes { output[i] = quickSearchResult{ ID: anime.MalID, Title: anime.DisplayTitle(), Type: anime.Type, Image: anime.ImageURL(), } } w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(output) } func (h *Handler) HandleDiscover(w http.ResponseWriter, r *http.Request) { var ( trending jikan.TopAnimeResult upcoming jikan.TopAnimeResult top jikan.TopAnimeResult watchlist []database.GetUserWatchListRow ) g, gCtx := errgroup.WithContext(r.Context()) g.Go(func() error { var err error trending, err = h.jikanClient.GetSeasonsNow(gCtx, 1) if err != nil { log.Printf("seasons now error: %v", err) } return nil }) g.Go(func() error { var err error upcoming, err = h.jikanClient.GetSeasonsUpcoming(gCtx, 1) if err != nil { log.Printf("seasons upcoming error: %v", err) } return nil }) g.Go(func() error { var err error top, err = h.jikanClient.GetTopAnime(gCtx, 1) if err != nil { log.Printf("top anime error: %v", err) } return nil }) user, _ := r.Context().Value(ctxpkg.UserKey).(*database.User) if user != nil { g.Go(func() error { var err error watchlist, err = h.db.GetUserWatchList(gCtx, user.ID) return err }) } g.Wait() seen := make(map[int]bool) uniqueTrending := make([]jikan.Anime, 0) for _, a := range trending.Animes { if !seen[a.MalID] { seen[a.MalID] = true uniqueTrending = append(uniqueTrending, a) } if len(uniqueTrending) >= 8 { break } } uniqueUpcoming := make([]jikan.Anime, 0) for _, a := range upcoming.Animes { if !seen[a.MalID] { seen[a.MalID] = true uniqueUpcoming = append(uniqueUpcoming, a) } if len(uniqueUpcoming) >= 8 { break } } uniqueTop := make([]jikan.Anime, 0) for _, a := range top.Animes { if !seen[a.MalID] { seen[a.MalID] = true uniqueTop = append(uniqueTop, a) } if len(uniqueTop) >= 8 { break } } watchlistMap := make(map[int64]bool) watchlistIDs := make([]int64, len(watchlist)) for i, entry := range watchlist { watchlistMap[entry.AnimeID] = true watchlistIDs[i] = entry.AnimeID } if err := templates.GetRenderer().ExecuteTemplate(r.Context(), w, "discover.gohtml", map[string]any{ "User": user, "CurrentPath": r.URL.Path, "Trending": uniqueTrending, "Upcoming": uniqueUpcoming, "Top": uniqueTop, "WatchlistMap": watchlistMap, "WatchlistIDs": watchlistIDs, }); err != nil { log.Printf("render error: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } func (h *Handler) HandleRandomAnime(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") anime, err := h.jikanClient.GetRandomAnime(r.Context()) if err != nil { log.Printf("random anime error: %v", err) w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch random anime"}) return } if anime.MalID == 0 { w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"error": "No anime found"}) return } w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]any{"data": anime}) } func (h *Handler) HandleAPIDiscoverAiring(w http.ResponseWriter, r *http.Request) { http.Error(w, "Not implemented yet", http.StatusNotImplemented) } func (h *Handler) HandleAPIDiscoverUpcoming(w http.ResponseWriter, r *http.Request) { http.Error(w, "Not implemented yet", http.StatusNotImplemented) } func (h *Handler) HandleStudioDetails(w http.ResponseWriter, r *http.Request) { renderNotFoundPage(r, w) } func (h *Handler) HandleAPIStudioAnime(w http.ResponseWriter, r *http.Request) { http.Error(w, "Not implemented yet", http.StatusNotImplemented) }