From 9b92f37cb17fe9dec810a54416f8bfd7c08b27a5 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Fri, 5 Jun 2026 16:14:38 +0200 Subject: [PATCH] feat: integrate profile search into top pick service --- internal/anime/handler.go | 54 +++++------ internal/anime/service.go | 183 +++++++++++++++++++++++++++----------- internal/domain/anime.go | 2 +- 3 files changed, 159 insertions(+), 80 deletions(-) diff --git a/internal/anime/handler.go b/internal/anime/handler.go index 849af81..175ce07 100644 --- a/internal/anime/handler.go +++ b/internal/anime/handler.go @@ -119,11 +119,11 @@ func (h *AnimeHandler) Register(r *gin.Engine) { r.GET("/api/catalog/airing", h.HandleCatalogAiring) r.GET("/api/catalog/popular", h.HandleCatalogPopular) r.GET("/api/catalog/continue", h.HandleCatalogContinue) + r.GET("/api/catalog/top-pick", h.HandleCatalogTopPickForYou) 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("/api/discover/for-you", h.HandleDiscoverForYou) r.GET("/schedule", h.HandleSchedule) r.GET("/api/schedule", h.HandleScheduleSection) r.GET("/browse", h.HandleBrowse) @@ -252,6 +252,32 @@ func (h *AnimeHandler) HandleCatalogContinue(c *gin.Context) { h.renderCatalogSection(c, "Continue") } +func (h *AnimeHandler) HandleCatalogTopPickForYou(c *gin.Context) { + userID := server.CurrentUserID(c) + + data, err := h.svc.GetTopPickForYou(c.Request.Context(), userID) + if err != nil { + observability.Warn( + "top_pick_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 = "TopPickForYou" + data.Fragment = "top_pick_for_you_section" + data.WatchlistMap = watchlistMap + c.HTML(http.StatusOK, "index.gohtml", data) +} + func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) { userID := server.CurrentUserID(c) data, err := h.svc.GetCatalogSection(c.Request.Context(), userID, section) @@ -288,32 +314,6 @@ func (h *AnimeHandler) HandleDiscoverTop(c *gin.Context) { h.renderDiscoverSection(c, "Top") } -func (h *AnimeHandler) HandleDiscoverForYou(c *gin.Context) { - userID := server.CurrentUserID(c) - - 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_for_you_section" - data.WatchlistMap = watchlistMap - c.HTML(http.StatusOK, "discover.gohtml", data) -} - func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) { userID := server.CurrentUserID(c) data, err := h.svc.GetDiscoverSection(c.Request.Context(), userID, section) diff --git a/internal/anime/service.go b/internal/anime/service.go index 4da7524..bc70afc 100644 --- a/internal/anime/service.go +++ b/internal/anime/service.go @@ -108,25 +108,28 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se }, nil } -func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (domain.DiscoverSectionData, error) { +func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) { if strings.TrimSpace(userID) == "" { - return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil + return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil } watchlist, err := s.repo.GetUserWatchList(ctx, userID) if err != nil { - return domain.DiscoverSectionData{}, err + return domain.CatalogSectionData{}, err } now := time.Now() seedPool := buildRecommendationSeeds(now, watchlist) if len(seedPool) == 0 { - return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil + return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil } type rankedCandidate struct { id int collaborativeScore float64 + profileSearchScore float64 + anime jikan.Anime + hasAnime bool } watchlistAnimeIDs := make(map[int]struct{}, len(watchlist)) @@ -137,6 +140,34 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{} } + candidatesByID := map[int]rankedCandidate{} + var candidatesByIDMu sync.Mutex + upsertCandidate := func(candidate rankedCandidate) { + if candidate.id <= 0 { + return + } + if _, exists := watchlistAnimeIDs[candidate.id]; exists { + return + } + + candidatesByIDMu.Lock() + defer candidatesByIDMu.Unlock() + + current, ok := candidatesByID[candidate.id] + if !ok { + candidatesByID[candidate.id] = candidate + return + } + + current.collaborativeScore += candidate.collaborativeScore + current.profileSearchScore += candidate.profileSearchScore + if candidate.hasAnime { + current.anime = candidate.anime + current.hasAnime = true + } + candidatesByID[candidate.id] = current + } + seedAnimes := make([]jikan.Anime, len(seedPool)) var seedFetchGroup errgroup.Group seedFetchGroup.SetLimit(4) @@ -153,18 +184,16 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do } if err := seedFetchGroup.Wait(); err != nil { - return domain.DiscoverSectionData{}, err + return domain.CatalogSectionData{}, err } - profile := buildTasteProfile(seedAnimes) + profile := buildTasteProfile(now, seedPool, seedAnimes) - recommended := map[int]rankedCandidate{} - var recommendedMu sync.Mutex - var g errgroup.Group - g.SetLimit(4) + var recommendationGroup errgroup.Group + recommendationGroup.SetLimit(4) for _, seed := range seedPool { - g.Go(func() error { + recommendationGroup.Go(func() error { recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seed.animeID) if recErr != nil { return recErr @@ -180,69 +209,119 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do if id == seed.animeID { continue } - if _, exists := watchlistAnimeIDs[id]; exists { - continue - } - recommendedMu.Lock() - current, ok := recommended[id] - if !ok { - recommended[id] = rankedCandidate{ - id: id, - collaborativeScore: float64(rec.Votes) * seed.weight, - } - recommendedMu.Unlock() - continue - } - current.collaborativeScore += float64(rec.Votes) * seed.weight - recommended[id] = current - recommendedMu.Unlock() + upsertCandidate(rankedCandidate{ + id: id, + collaborativeScore: float64(rec.Votes) * seed.weight, + }) } return nil }) } - if err := g.Wait(); err != nil { - return domain.DiscoverSectionData{}, err + if err := recommendationGroup.Wait(); err != nil { + return domain.CatalogSectionData{}, err } - if len(recommended) == 0 { - return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil + profileQueries := buildProfileSearchQueries(profile) + var profileSearchGroup errgroup.Group + profileSearchGroup.SetLimit(3) + + for _, query := range profileQueries { + profileSearchGroup.Go(func() error { + res, searchErr := s.jikan.SearchAdvanced( + ctx, + "", + "", + "", + "score", + "desc", + query.genreIDs, + query.studioID, + true, + 1, + forYouProfileSearchLimit, + ) + if searchErr != nil { + observability.Warn( + "top_pick_profile_search_failed", + "anime", + "", + map[string]any{ + "genres": query.genreIDs, + "studio_id": query.studioID, + }, + searchErr, + ) + return nil + } + + for i, anime := range res.Animes { + if anime.MalID <= 0 { + continue + } + upsertCandidate(rankedCandidate{ + id: anime.MalID, + profileSearchScore: query.weight * profileSearchRankWeight(i), + anime: anime, + hasAnime: true, + }) + } + return nil + }) } - rankedIDs := make([]rankedCandidate, 0, len(recommended)) - for _, item := range recommended { + if err := profileSearchGroup.Wait(); err != nil { + return domain.CatalogSectionData{}, err + } + + if len(candidatesByID) == 0 { + return domain.CatalogSectionData{Animes: []domain.Anime{}}, nil + } + + rankedIDs := make([]rankedCandidate, 0, len(candidatesByID)) + for _, item := range candidatesByID { rankedIDs = append(rankedIDs, item) } sort.Slice(rankedIDs, func(i, j int) bool { - if rankedIDs[i].collaborativeScore == rankedIDs[j].collaborativeScore { + left := rankedCandidateRetrievalScore(rankedIDs[i].collaborativeScore, rankedIDs[i].profileSearchScore) + right := rankedCandidateRetrievalScore(rankedIDs[j].collaborativeScore, rankedIDs[j].profileSearchScore) + if left == right { return rankedIDs[i].id < rankedIDs[j].id } - return rankedIDs[i].collaborativeScore > rankedIDs[j].collaborativeScore + return left > right }) limit := min(len(rankedIDs), forYouCandidateFetchLimit) candidates := make([]recommendationCandidate, 0, limit) var candidatesMu sync.Mutex - g.SetLimit(6) + var detailGroup errgroup.Group + detailGroup.SetLimit(6) - for i := range limit { - g.Go(func() error { - anime, fetchErr := s.jikan.GetAnimeByID(ctx, rankedIDs[i].id) - if fetchErr != nil { - observability.Warn( - "recommendation_anime_fetch_failed", - "anime", - "", - map[string]any{"anime_id": rankedIDs[i].id}, - fetchErr, - ) - return nil + for i := 0; i < limit; i++ { + item := rankedIDs[i] + detailGroup.Go(func() error { + anime := item.anime + if !item.hasAnime || !hasTasteMetadata(anime) { + fetchedAnime, fetchErr := s.jikan.GetAnimeByID(ctx, item.id) + if fetchErr != nil { + observability.Warn( + "recommendation_anime_fetch_failed", + "anime", + "", + map[string]any{"anime_id": item.id}, + fetchErr, + ) + return nil + } + anime = fetchedAnime } + candidate := scoreRecommendationCandidate( now, profile, anime, - rankedIDs[i].collaborativeScore, + item.collaborativeScore, + item.profileSearchScore, ) candidatesMu.Lock() candidates = append(candidates, candidate) @@ -251,8 +330,8 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do }) } - if err := g.Wait(); err != nil { - return domain.DiscoverSectionData{}, err + if err := detailGroup.Wait(); err != nil { + return domain.CatalogSectionData{}, err } sort.Slice(candidates, func(i, j int) bool { @@ -262,7 +341,7 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do return candidates[i].score > candidates[j].score }) - return domain.DiscoverSectionData{ + return domain.CatalogSectionData{ Animes: rerankRecommendationCandidates(candidates, forYouResultLimit), }, nil } diff --git a/internal/domain/anime.go b/internal/domain/anime.go index 6e35eb6..6f3300c 100644 --- a/internal/domain/anime.go +++ b/internal/domain/anime.go @@ -133,11 +133,11 @@ type ReviewEntry struct { type AnimeCatalogService interface { GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error) + GetTopPickForYou(ctx context.Context, userID string) (CatalogSectionData, error) } type AnimeDiscoverService interface { 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) }