diff --git a/internal/anime/handler/handler.go b/internal/anime/handler/handler.go index b011e3c..902bf87 100644 --- a/internal/anime/handler/handler.go +++ b/internal/anime/handler/handler.go @@ -60,6 +60,9 @@ func (h *AnimeHandler) Register(r *gin.Engine) { r.GET("/api/discover/trending", h.HandleDiscoverTrending) r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming) r.GET("/api/discover/top", h.HandleDiscoverTop) + r.GET("/api/discover/for-you", h.HandleDiscoverForYou) + 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) @@ -220,6 +223,36 @@ func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) { h.renderDiscoverSection(c, "Top") } +func (h *AnimeHandler) HandleDiscoverForYou(c *gin.Context) { + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID + } + + data, err := h.svc.GetDiscoverForYou(c.Request.Context(), userID) + if err != nil { + observability.Warn( + "discover_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 = "ForYou" + data.Fragment = "discover_row" + data.WatchlistMap = watchlistMap + c.HTML(http.StatusOK, "discover.gohtml", data) +} + func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) { user, _ := c.Get("User") userID := "" @@ -250,6 +283,45 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) { c.HTML(http.StatusOK, "discover.gohtml", data) } +func (h *AnimeHandler) HandleSchedule(c *gin.Context) { + user, _ := c.Get("User") + c.HTML(http.StatusOK, "schedule.gohtml", gin.H{ + "CurrentPath": "/schedule", + "User": user, + }) +} + +func (h *AnimeHandler) HandleScheduleSection(c *gin.Context) { + user, _ := c.Get("User") + userID := "" + if u, ok := user.(*domain.User); ok { + userID = u.ID + } + + animes, err := h.svc.GetAiringSchedule(c.Request.Context(), userID) + if err != nil { + observability.Warn( + "schedule_fetch_failed", + "anime", + "", + map[string]any{ + "user_id": userID, + }, + err, + ) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, animes) + + c.HTML(http.StatusOK, "schedule.gohtml", gin.H{ + "_fragment": "schedule_section", + "Animes": animes, + "WatchlistMap": watchlistMap, + }) +} + func (h *AnimeHandler) HandleBrowse(c *gin.Context) { q := c.Query("q") animeType := c.Query("type") diff --git a/internal/anime/service/service.go b/internal/anime/service/service.go index 1d2422f..d03df5a 100644 --- a/internal/anime/service/service.go +++ b/internal/anime/service/service.go @@ -2,10 +2,14 @@ package service import ( "context" + "errors" "mal/integrations/jikan" "mal/internal/db" "mal/internal/domain" "math/rand" + "sort" + "strings" + "sync" "time" "golang.org/x/sync/errgroup" @@ -94,6 +98,166 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se }, nil } +func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (domain.DiscoverSectionData, error) { + if strings.TrimSpace(userID) == "" { + return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil + } + + watchlist, err := s.repo.GetUserWatchList(ctx, userID) + if err != nil { + return domain.DiscoverSectionData{}, err + } + + seedIDs := make([]int, 0, 5) + for _, entry := range watchlist { + status := strings.TrimSpace(entry.Status) + if status != "watching" && status != "completed" { + continue + } + if entry.AnimeID <= 0 { + continue + } + seedIDs = append(seedIDs, int(entry.AnimeID)) + if len(seedIDs) >= 5 { + break + } + } + + if len(seedIDs) == 0 { + return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil + } + + type ranked struct { + id int + votes int + } + + recommended := map[int]ranked{} + var g errgroup.Group + g.SetLimit(4) + + for _, seedID := range seedIDs { + g.Go(func() error { + recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seedID) + if recErr != nil { + return recErr + } + for _, rec := range recs { + id := rec.Entry.MalID + if id <= 0 { + continue + } + if id == seedID { + continue + } + current, ok := recommended[id] + if !ok { + recommended[id] = ranked{id: id, votes: rec.Votes} + continue + } + current.votes += rec.Votes + recommended[id] = current + } + return nil + }) + } + + if err := g.Wait(); err != nil { + return domain.DiscoverSectionData{}, err + } + + if len(recommended) == 0 { + return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil + } + + rankedIDs := make([]ranked, 0, len(recommended)) + for _, item := range recommended { + rankedIDs = append(rankedIDs, item) + } + sort.Slice(rankedIDs, func(i, j int) bool { + if rankedIDs[i].votes == rankedIDs[j].votes { + return rankedIDs[i].id < rankedIDs[j].id + } + return rankedIDs[i].votes > rankedIDs[j].votes + }) + + limit := 12 + if len(rankedIDs) < limit { + limit = len(rankedIDs) + } + + animes := make([]domain.Anime, 0, limit) + for i := 0; i < limit; i++ { + anime, fetchErr := s.jikan.GetAnimeByID(ctx, rankedIDs[i].id) + if fetchErr != nil { + continue + } + animes = append(animes, anime) + } + + return domain.DiscoverSectionData{Animes: animes}, nil +} + +func (s *animeService) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) { + if strings.TrimSpace(userID) == "" { + return []domain.Anime{}, nil + } + + watchlist, err := s.repo.GetUserWatchList(ctx, userID) + if err != nil { + return nil, err + } + + ids := make([]int, 0, 50) + for _, entry := range watchlist { + status := strings.TrimSpace(entry.Status) + if status != "watching" && status != "plan_to_watch" { + continue + } + if !entry.Airing.Valid || !entry.Airing.Bool { + continue + } + if entry.AnimeID <= 0 { + continue + } + ids = append(ids, int(entry.AnimeID)) + if len(ids) >= 50 { + break + } + } + + if len(ids) == 0 { + return []domain.Anime{}, nil + } + + animes := make([]domain.Anime, 0, len(ids)) + var g errgroup.Group + g.SetLimit(6) + var mu sync.Mutex + + for _, id := range ids { + g.Go(func() error { + anime, fetchErr := s.jikan.GetAnimeByID(ctx, id) + if fetchErr != nil { + return fetchErr + } + mu.Lock() + animes = append(animes, anime) + mu.Unlock() + return nil + }) + } + + if err := g.Wait(); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, err + } + return animes, nil + } + + return animes, nil +} + func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) { return s.jikan.GetAnimeByID(ctx, id) } diff --git a/internal/domain/anime.go b/internal/domain/anime.go index 01f9f1c..9c8f008 100644 --- a/internal/domain/anime.go +++ b/internal/domain/anime.go @@ -19,6 +19,8 @@ type ReviewEntry = jikan.ReviewEntry type AnimeService interface { GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error) GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error) + GetDiscoverForYou(ctx context.Context, userID string) (DiscoverSectionData, error) + GetAiringSchedule(ctx context.Context, userID string) ([]Anime, error) GetAnimeByID(ctx context.Context, id int) (Anime, error) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error) GetProducerNameByID(ctx context.Context, id int) (string, error) diff --git a/templates/discover.gohtml b/templates/discover.gohtml index 409731f..2058825 100644 --- a/templates/discover.gohtml +++ b/templates/discover.gohtml @@ -72,6 +72,17 @@ + {{/* For You Section */}} + + + For You + Based on your watchlist + + + {{template "discover_skeleton"}} + + + {{/* Upcoming Section */}} @@ -124,6 +135,14 @@ {{end}} +{{define "discover_row"}} + + {{range .Animes}} + {{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}} + {{end}} + +{{end}} + {{define "discover_skeleton"}} {{range (seq 8)}}