refactor: use recommendation engine in discover for-you

This commit is contained in:
2026-06-04 16:10:15 +02:00
committed by Milas Holsting
parent f880205f5c
commit 91e0280ec7

View File

@@ -118,55 +118,84 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do
return domain.DiscoverSectionData{}, err return domain.DiscoverSectionData{}, err
} }
seedIDs := make([]int, 0, 5) now := time.Now()
for _, entry := range watchlist { seedPool := buildRecommendationSeeds(now, watchlist)
status := strings.TrimSpace(entry.Status) if len(seedPool) == 0 {
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 return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
} }
type ranked struct { type rankedCandidate struct {
id int id int
votes int collaborativeScore float64
} }
recommended := map[int]ranked{} watchlistAnimeIDs := make(map[int]struct{}, len(watchlist))
for _, entry := range watchlist {
if entry.AnimeID <= 0 {
continue
}
watchlistAnimeIDs[int(entry.AnimeID)] = struct{}{}
}
seedAnimes := make([]jikan.Anime, len(seedPool))
var seedFetchGroup errgroup.Group
seedFetchGroup.SetLimit(4)
for i, seed := range seedPool {
seedFetchGroup.Go(func() error {
anime, fetchErr := s.jikan.GetAnimeByID(ctx, seed.animeID)
if fetchErr != nil {
return fetchErr
}
seedAnimes[i] = anime
return nil
})
}
if err := seedFetchGroup.Wait(); err != nil {
return domain.DiscoverSectionData{}, err
}
profile := buildTasteProfile(seedAnimes)
recommended := map[int]rankedCandidate{}
var recommendedMu sync.Mutex
var g errgroup.Group var g errgroup.Group
g.SetLimit(4) g.SetLimit(4)
for _, seedID := range seedIDs { for _, seed := range seedPool {
g.Go(func() error { g.Go(func() error {
recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seedID) recs, recErr := s.jikan.GetAnimeRecommendations(ctx, seed.animeID)
if recErr != nil { if recErr != nil {
return recErr return recErr
} }
for _, rec := range recs { for i, rec := range recs {
if i >= forYouMaxRecommendations {
break
}
id := rec.Entry.MalID id := rec.Entry.MalID
if id <= 0 { if id <= 0 {
continue continue
} }
if id == seedID { if id == seed.animeID {
continue continue
} }
if _, exists := watchlistAnimeIDs[id]; exists {
continue
}
recommendedMu.Lock()
current, ok := recommended[id] current, ok := recommended[id]
if !ok { if !ok {
recommended[id] = ranked{id: id, votes: rec.Votes} recommended[id] = rankedCandidate{
id: id,
collaborativeScore: float64(rec.Votes) * seed.weight,
}
recommendedMu.Unlock()
continue continue
} }
current.votes += rec.Votes current.collaborativeScore += float64(rec.Votes) * seed.weight
recommended[id] = current recommended[id] = current
recommendedMu.Unlock()
} }
return nil return nil
}) })
@@ -180,20 +209,20 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do
return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil return domain.DiscoverSectionData{Animes: []domain.Anime{}}, nil
} }
rankedIDs := make([]ranked, 0, len(recommended)) rankedIDs := make([]rankedCandidate, 0, len(recommended))
for _, item := range recommended { for _, item := range recommended {
rankedIDs = append(rankedIDs, item) rankedIDs = append(rankedIDs, item)
} }
sort.Slice(rankedIDs, func(i, j int) bool { sort.Slice(rankedIDs, func(i, j int) bool {
if rankedIDs[i].votes == rankedIDs[j].votes { if rankedIDs[i].collaborativeScore == rankedIDs[j].collaborativeScore {
return rankedIDs[i].id < rankedIDs[j].id return rankedIDs[i].id < rankedIDs[j].id
} }
return rankedIDs[i].votes > rankedIDs[j].votes return rankedIDs[i].collaborativeScore > rankedIDs[j].collaborativeScore
}) })
limit := min(len(rankedIDs), 12) limit := min(len(rankedIDs), forYouCandidateFetchLimit)
candidates := make([]recommendationCandidate, 0, limit)
animes := make([]domain.Anime, limit) var candidatesMu sync.Mutex
g.SetLimit(6) g.SetLimit(6)
for i := range limit { for i := range limit {
@@ -209,7 +238,15 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do
) )
return nil return nil
} }
animes[i] = domain.Anime{Anime: anime} candidate := scoreRecommendationCandidate(
now,
profile,
anime,
rankedIDs[i].collaborativeScore,
)
candidatesMu.Lock()
candidates = append(candidates, candidate)
candidatesMu.Unlock()
return nil return nil
}) })
} }
@@ -218,15 +255,16 @@ func (s *animeService) GetDiscoverForYou(ctx context.Context, userID string) (do
return domain.DiscoverSectionData{}, err return domain.DiscoverSectionData{}, err
} }
// Filter out empty animes if any fetch failed silently sort.Slice(candidates, func(i, j int) bool {
filtered := make([]domain.Anime, 0, len(animes)) if candidates[i].score == candidates[j].score {
for _, a := range animes { return candidates[i].anime.MalID < candidates[j].anime.MalID
if a.MalID > 0 {
filtered = append(filtered, a)
} }
} return candidates[i].score > candidates[j].score
})
return domain.DiscoverSectionData{Animes: filtered}, nil return domain.DiscoverSectionData{
Animes: rerankRecommendationCandidates(candidates, forYouResultLimit),
}, nil
} }
func (s *animeService) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) { func (s *animeService) GetAiringSchedule(ctx context.Context, userID string) ([]domain.Anime, error) {