feat: add For You recommendations to discover
This commit is contained in:
@@ -60,6 +60,9 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
|
|||||||
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
r.GET("/api/discover/trending", h.HandleDiscoverTrending)
|
||||||
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
r.GET("/api/discover/upcoming", h.HandleDiscoverUpcoming)
|
||||||
r.GET("/api/discover/top", h.HandleDiscoverTop)
|
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("/browse", h.HandleBrowse)
|
||||||
r.GET("/anime/:id", h.HandleAnimeDetails)
|
r.GET("/anime/:id", h.HandleAnimeDetails)
|
||||||
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
|
||||||
@@ -220,6 +223,36 @@ func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) {
|
|||||||
h.renderDiscoverSection(c, "Top")
|
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) {
|
func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
|
||||||
user, _ := c.Get("User")
|
user, _ := c.Get("User")
|
||||||
userID := ""
|
userID := ""
|
||||||
@@ -250,6 +283,45 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) {
|
|||||||
c.HTML(http.StatusOK, "discover.gohtml", data)
|
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) {
|
func (h *AnimeHandler) HandleBrowse(c *gin.Context) {
|
||||||
q := c.Query("q")
|
q := c.Query("q")
|
||||||
animeType := c.Query("type")
|
animeType := c.Query("type")
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"mal/integrations/jikan"
|
"mal/integrations/jikan"
|
||||||
"mal/internal/db"
|
"mal/internal/db"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
@@ -94,6 +98,166 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se
|
|||||||
}, nil
|
}, 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) {
|
func (s *animeService) GetAnimeByID(ctx context.Context, id int) (domain.Anime, error) {
|
||||||
return s.jikan.GetAnimeByID(ctx, id)
|
return s.jikan.GetAnimeByID(ctx, id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type ReviewEntry = jikan.ReviewEntry
|
|||||||
type AnimeService interface {
|
type AnimeService interface {
|
||||||
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
|
GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error)
|
||||||
GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, 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)
|
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)
|
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)
|
GetProducerNameByID(ctx context.Context, id int) (string, error)
|
||||||
|
|||||||
@@ -72,6 +72,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{{/* For You Section */}}
|
||||||
|
<section class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-base font-normal text-foreground">For You</h2>
|
||||||
|
<span class="text-sm text-foreground-muted">Based on your watchlist</span>
|
||||||
|
</div>
|
||||||
|
<div hx-get="/api/discover/for-you" hx-trigger="load" hx-swap="outerHTML">
|
||||||
|
{{template "discover_skeleton"}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{{/* Upcoming Section */}}
|
{{/* Upcoming Section */}}
|
||||||
<section class="flex flex-col gap-4">
|
<section class="flex flex-col gap-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -124,6 +135,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "discover_row"}}
|
||||||
|
<div class="grid gap-4 [grid-template-columns:repeat(auto-fit,minmax(160px,1fr))]">
|
||||||
|
{{range .Animes}}
|
||||||
|
{{template "anime_card" dict "Anime" . "WithActions" true "IsWatchlist" (index $.WatchlistMap .MalID)}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "discover_skeleton"}}
|
{{define "discover_skeleton"}}
|
||||||
<div class="discover-grid">
|
<div class="discover-grid">
|
||||||
{{range (seq 8)}}
|
{{range (seq 8)}}
|
||||||
|
|||||||
Reference in New Issue
Block a user