feat: add For You recommendations to discover

This commit is contained in:
2026-05-26 20:16:09 +02:00
parent f2b4a7994a
commit 71dd130744
4 changed files with 257 additions and 0 deletions

View File

@@ -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")

View File

@@ -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)
} }

View File

@@ -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)

View File

@@ -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)}}