diff --git a/internal/anime/handler/handler.go b/internal/anime/handler/handler.go new file mode 100644 index 0000000..78f122b --- /dev/null +++ b/internal/anime/handler/handler.go @@ -0,0 +1,268 @@ +package handler + +import ( + "context" + "fmt" + "log" + "mal/internal/domain" + "mal/internal/server" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +type AnimeHandler struct { + svc domain.AnimeService +} + +func NewAnimeHandler(svc domain.AnimeService) *AnimeHandler { + return &AnimeHandler{svc: svc} +} + +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("/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("/browse", h.HandleBrowse) + r.GET("/anime/:id", h.HandleAnimeDetails) + r.GET("/api/watch-order", h.HandleHTMLWatchOrder) + r.GET("/api/search-quick", h.HandleQuickSearch) + r.GET("/api/jikan/random/anime", h.HandleRandomAnime) +} + +func (h *AnimeHandler) HandleCatalog(c *gin.Context) { + c.HTML(http.StatusOK, "index.gohtml", gin.H{ + "CurrentPath": "/", + }) +} + +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) renderCatalogSection(c *gin.Context, section string) { + userID := "" // TODO: get from auth context + data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section) + if err != nil { + log.Printf("catalog %s error: %v", section, err) + return + } + + data["Section"] = section + data["_fragment"] = "catalog_section" + c.HTML(http.StatusOK, "index.gohtml", data) +} + +func (h *AnimeHandler) HandleDiscover(c *gin.Context) { + c.HTML(http.StatusOK, "discover.gohtml", gin.H{ + "CurrentPath": "/discover", + }) +} + +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 := "" // TODO: get from auth context + data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section) + if err != nil { + log.Printf("discover %s error: %v", section, err) + return + } + + data["Section"] = section + data["_fragment"] = "discover_section" + c.HTML(http.StatusOK, "discover.gohtml", data) +} + +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" + + var genres []int + for _, g := range c.QueryArray("genres") { + id, _ := strconv.Atoi(g) + if id > 0 { + genres = append(genres, id) + } + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + + res, err := h.svc.SearchAdvanced(c.Request.Context(), q, animeType, status, orderBy, sort, genres, sfw, page, 24) + if err != nil { + log.Printf("browse error: %v", err) + } + + if c.GetHeader("HX-Request") == "true" { + c.HTML(http.StatusOK, "browse.gohtml", gin.H{ + "_fragment": "anime_card_scroll", + "Animes": res.Animes, + "NextPage": page + 1, + "HasNextPage": res.HasNextPage, + "Query": q, + "Type": animeType, + "Status": status, + "OrderBy": orderBy, + "Sort": sort, + "Genres": genres, + "SFW": sfw, + }) + return + } + + genresList, _ := h.svc.GetGenres(c.Request.Context()) + + c.HTML(http.StatusOK, "browse.gohtml", gin.H{ + "CurrentPath": "/browse", + "Query": q, + "Type": animeType, + "Status": status, + "OrderBy": orderBy, + "Sort": sort, + "Genres": genres, + "SFW": sfw, + "GenresList": genresList, + "Animes": res.Animes, + "HasNextPage": res.HasNextPage, + "NextPage": page + 1, + }) +} + +func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + if id <= 0 { + c.Status(http.StatusNotFound) + return + } + + section := c.Query("section") + if section != "" && c.GetHeader("HX-Request") == "true" { + var data any + var tplName string + var err error + switch section { + case "characters": + data, err = h.svc.GetCharacters(c.Request.Context(), id) + tplName = "anime_characters" + case "recommendations": + data, err = h.svc.GetRecommendations(c.Request.Context(), id) + tplName = "anime_recommendations" + } + + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.HTML(http.StatusOK, "anime.gohtml", gin.H{ + "_fragment": tplName, + "Data": data, + }) + return + } + + anime, err := h.svc.GetAnimeByID(c.Request.Context(), id) + if err != nil { + c.Status(http.StatusNotFound) + return + } + + c.HTML(http.StatusOK, "anime.gohtml", gin.H{ + "Anime": anime, + "CurrentPath": fmt.Sprintf("/anime/%d", id), + }) +} + +func (h *AnimeHandler) HandleHTMLWatchOrder(c *gin.Context) { + id, _ := strconv.Atoi(c.Query("animeId")) + if id <= 0 { + c.Status(http.StatusBadRequest) + return + } + + relations, err := h.svc.GetRelations(c.Request.Context(), id) + if err != nil { + c.Status(http.StatusInternalServerError) + return + } + + c.HTML(http.StatusOK, "anime.gohtml", gin.H{ + "_fragment": "watch_order", + "Relations": relations, + "AnimeID": id, + }) +} + +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, true, 1, 5) + if err != nil { + c.JSON(http.StatusOK, []any{}) + return + } + + type quickSearchResult struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Image string `json:"image"` + } + + 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(), + } + } + c.JSON(http.StatusOK, output) +} + +func (h *AnimeHandler) HandleRandomAnime(c *gin.Context) { + anime, err := h.svc.GetRandomAnime(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch random anime"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": anime}) +} diff --git a/internal/anime/module.go b/internal/anime/module.go new file mode 100644 index 0000000..9dbe633 --- /dev/null +++ b/internal/anime/module.go @@ -0,0 +1,23 @@ +package anime + +import ( + "mal/internal/anime/handler" + "mal/internal/anime/repository" + "mal/internal/anime/service" + "mal/internal/server" + + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide( + repository.NewAnimeRepository, + service.NewAnimeService, + handler.NewAnimeHandler, + ), + fx.Provide( + server.AsRouteRegister(func(h *handler.AnimeHandler) server.RouteRegister { + return h + }), + ), +) diff --git a/internal/anime/repository/repository.go b/internal/anime/repository/repository.go new file mode 100644 index 0000000..3c54e32 --- /dev/null +++ b/internal/anime/repository/repository.go @@ -0,0 +1,27 @@ +package repository + +import ( + "context" + "mal/internal/db" + "mal/internal/domain" +) + +type animeRepository struct { + queries *db.Queries +} + +func NewAnimeRepository(queries *db.Queries) domain.AnimeRepository { + return &animeRepository{queries: queries} +} + +func (r *animeRepository) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) { + return r.queries.GetUserWatchList(ctx, userID) +} + +func (r *animeRepository) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) { + return r.queries.GetWatchListEntry(ctx, params) +} + +func (r *animeRepository) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) { + return r.queries.GetContinueWatchingEntries(ctx, userID) +} diff --git a/internal/anime/service/service.go b/internal/anime/service/service.go new file mode 100644 index 0000000..6e754f7 --- /dev/null +++ b/internal/anime/service/service.go @@ -0,0 +1,157 @@ +package service + +import ( + "context" + "mal/integrations/jikan" + "mal/internal/db" + "mal/internal/domain" + + "golang.org/x/sync/errgroup" +) + +type animeService struct { + jikan *jikan.Client + repo domain.AnimeRepository +} + +func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.AnimeService { + return &animeService{jikan: jikan, repo: repo} +} + +func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error) { + var ( + res jikan.TopAnimeResult + cw []db.GetContinueWatchingEntriesRow + watchlist []db.GetUserWatchListRow + ) + + g, gCtx := errgroup.WithContext(ctx) + + g.Go(func() error { + var err error + switch section { + case "Airing": + res, err = s.jikan.GetSeasonsNow(gCtx, 1) + case "Popular": + res, err = s.jikan.GetTopAnime(gCtx, 1) + } + return err + }) + + if userID != "" { + g.Go(func() error { + if section == "Continue" { + var err error + cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID) + return err + } + return nil + }) + g.Go(func() error { + var err error + watchlist, err = s.repo.GetUserWatchList(gCtx, userID) + return err + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + + animes := res.Animes + if len(animes) > 6 { + animes = animes[:6] + } + + watchlistMap := make(map[int64]bool) + for _, entry := range watchlist { + watchlistMap[entry.AnimeID] = true + } + + return map[string]any{ + "Animes": animes, + "ContinueWatching": cw, + "WatchlistMap": watchlistMap, + }, nil +} + +func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error) { + var ( + res jikan.TopAnimeResult + watchlist []db.GetUserWatchListRow + ) + + g, gCtx := errgroup.WithContext(ctx) + + g.Go(func() error { + var err error + switch section { + case "Trending": + res, err = s.jikan.GetSeasonsNow(gCtx, 1) + case "Upcoming": + res, err = s.jikan.GetSeasonsUpcoming(gCtx, 1) + case "Top": + res, err = s.jikan.GetTopAnime(gCtx, 1) + } + return err + }) + + if userID != "" { + g.Go(func() error { + var err error + watchlist, err = s.repo.GetUserWatchList(gCtx, userID) + return err + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + + animes := res.Animes + if len(animes) > 8 { + animes = animes[:8] + } + + watchlistMap := make(map[int64]bool) + for _, entry := range watchlist { + watchlistMap[entry.AnimeID] = true + } + + return map[string]any{ + "Animes": animes, + "WatchlistMap": watchlistMap, + }, nil +} + +func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) { + return s.jikan.GetAnimeByID(ctx, id) +} + +func (s *animeService) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResponse, error) { + return s.jikan.SearchAdvanced(ctx, q, animeType, status, orderBy, sort, genres, sfw, page, limit) +} + +func (s *animeService) GetGenres(ctx context.Context) ([]domain.Genre, error) { + return s.jikan.GetAnimeGenres(ctx) +} + +func (s *animeService) GetCharacters(ctx context.Context, id int) ([]domain.Character, error) { + return s.jikan.GetAnimeCharacters(ctx, id) +} + +func (s *animeService) GetRecommendations(ctx context.Context, id int) ([]domain.Recommendation, error) { + return s.jikan.GetAnimeRecommendations(ctx, id) +} + +func (s *animeService) GetRelations(ctx context.Context, id int) ([]jikan.Relation, error) { + return s.jikan.GetFullRelations(ctx, id) +} + +func (s *animeService) GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error) { + return s.jikan.GetEpisodes(ctx, id, page) +} + +func (s *animeService) GetRandomAnime(ctx context.Context) (domain.Anime, error) { + return s.jikan.GetRandomAnime(ctx) +} diff --git a/internal/app/app.go b/internal/app/app.go index 1c7bb17..214abe4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,6 +3,7 @@ package app import ( "mal/internal/database" "mal/internal/auth" + "mal/internal/anime" "mal/internal/server" "mal/internal/templates" @@ -16,6 +17,7 @@ func NewApp() *fx.App { database.Module, jikan.Module, auth.Module, + anime.Module, templates.Module, server.Module, fx.Decorate(func(r *templates.Renderer) render.HTMLRender { diff --git a/internal/domain/anime.go b/internal/domain/anime.go new file mode 100644 index 0000000..4759d72 --- /dev/null +++ b/internal/domain/anime.go @@ -0,0 +1,32 @@ +package domain + +import ( + "context" + "mal/integrations/jikan" + "mal/internal/db" +) + +type Anime = jikan.Anime +type TopAnimeResult = jikan.TopAnimeResult +type Genre = jikan.Genre +type Character = jikan.Character +type Recommendation = jikan.Recommendation + +type AnimeService interface { + GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error) + GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error) + GetAnimeByID(ctx context.Context, id int) (Anime, error) + SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResponse, error) + GetGenres(ctx context.Context) ([]Genre, error) + GetCharacters(ctx context.Context, id int) ([]Character, error) + GetRecommendations(ctx context.Context, id int) ([]Recommendation, error) + GetRelations(ctx context.Context, id int) ([]jikan.Relation, error) + GetEpisodes(ctx context.Context, id int, page int) (jikan.EpisodesResponse, error) + GetRandomAnime(ctx context.Context) (Anime, error) +} + +type AnimeRepository interface { + GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) + GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) + GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) +} diff --git a/internal/templates/renderer.go b/internal/templates/renderer.go index f634c87..fdc3bb2 100644 --- a/internal/templates/renderer.go +++ b/internal/templates/renderer.go @@ -191,6 +191,13 @@ func (h HTMLRender) Render(w http.ResponseWriter) error { if !ok { return fmt.Errorf("template %s not found", h.Name) } + + if block, ok := h.Data.(map[string]any)["_fragment"]; ok { + if blockStr, ok := block.(string); ok { + return tmpl.ExecuteTemplate(w, blockStr, h.Data) + } + } + return tmpl.ExecuteTemplate(w, "base.gohtml", h.Data) }