veza/veza-backend-api/internal/services/playlist_recommendation_service.go

338 lines
10 KiB
Go

package services
import (
"context"
"fmt"
"github.com/google/uuid"
"math"
"sort"
"time"
"veza-backend-api/internal/models"
"go.uber.org/zap"
"gorm.io/gorm"
)
// PlaylistRecommendationService gère les recommandations de playlists
// T0498: Create Playlist Recommendations
type PlaylistRecommendationService struct {
db *gorm.DB
playlistService PlaylistServiceForRecommendation
playlistFollowService PlaylistFollowServiceForRecommendation
logger *zap.Logger
}
// PlaylistServiceForRecommendation définit l'interface minimale nécessaire pour les recommandations
// MIGRATION UUID: userID migré vers *uuid.UUID, playlistID en uuid.UUID
type PlaylistServiceForRecommendation interface {
GetPlaylist(ctx context.Context, playlistID uuid.UUID, userID *uuid.UUID) (*models.Playlist, error)
GetPlaylists(ctx context.Context, currentUserID *uuid.UUID, filterUserID *uuid.UUID, page, limit int) ([]*models.Playlist, int64, error)
}
// PlaylistFollowServiceForRecommendation définit l'interface minimale nécessaire pour les recommandations
type PlaylistFollowServiceForRecommendation interface {
GetFollowedPlaylists(ctx context.Context, userID uuid.UUID) ([]*models.Playlist, error)
}
// NewPlaylistRecommendationService crée un nouveau service de recommandations de playlists
func NewPlaylistRecommendationService(
db *gorm.DB,
playlistService PlaylistServiceForRecommendation,
playlistFollowService PlaylistFollowServiceForRecommendation,
logger *zap.Logger,
) *PlaylistRecommendationService {
if logger == nil {
logger = zap.NewNop()
}
return &PlaylistRecommendationService{
db: db,
playlistService: playlistService,
playlistFollowService: playlistFollowService,
logger: logger,
}
}
// RecommendationScore représente un score de recommandation pour une playlist
type RecommendationScore struct {
Playlist *models.Playlist
Score float64
Reason string
}
// GetRecommendationsParams représente les paramètres pour obtenir des recommandations
// MIGRATION UUID: UserID migré vers uuid.UUID
type GetRecommendationsParams struct {
UserID uuid.UUID
Limit int // Nombre de recommandations à retourner (défaut: 20)
MinScore float64 // Score minimum pour inclure une recommandation (défaut: 0.1)
IncludeOwn bool // Inclure les playlists de l'utilisateur (défaut: false)
}
// GetRecommendations retourne des recommandations de playlists pour un utilisateur
// T0498: Create Playlist Recommendations
func (s *PlaylistRecommendationService) GetRecommendations(
ctx context.Context,
params GetRecommendationsParams,
) ([]*RecommendationScore, error) {
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}
if params.MinScore < 0 {
params.MinScore = 0.1
}
// Récupérer les playlists suivies par l'utilisateur
followedPlaylists, err := s.playlistFollowService.GetFollowedPlaylists(ctx, params.UserID)
if err != nil {
s.logger.Warn("Failed to get followed playlists for recommendations",
zap.String("user_id", params.UserID.String()),
zap.Error(err))
followedPlaylists = []*models.Playlist{}
}
// Récupérer toutes les playlists publiques (ou accessibles)
allPlaylists, _, err := s.playlistService.GetPlaylists(ctx, &params.UserID, nil, 1, 1000)
if err != nil {
return nil, fmt.Errorf("failed to get playlists: %w", err)
}
// Calculer les scores pour chaque playlist
scores := make([]*RecommendationScore, 0)
scoreMap := make(map[uuid.UUID]*RecommendationScore)
for _, playlist := range allPlaylists {
// Ignorer les playlists de l'utilisateur si IncludeOwn est false
if !params.IncludeOwn && playlist.UserID == params.UserID {
continue
}
// Ignorer les playlists déjà suivies
if s.isPlaylistFollowed(playlist.ID, followedPlaylists) {
continue
}
score := s.calculateRecommendationScore(ctx, playlist, params.UserID, followedPlaylists)
if score.Score >= params.MinScore {
scoreMap[playlist.ID] = score
}
}
// Convertir la map en slice
for _, score := range scoreMap {
scores = append(scores, score)
}
// Trier par score décroissant
sort.Slice(scores, func(i, j int) bool {
return scores[i].Score > scores[j].Score
})
// Limiter le nombre de résultats
if len(scores) > params.Limit {
scores = scores[:params.Limit]
}
s.logger.Info("Playlist recommendations generated",
zap.String("user_id", params.UserID.String()),
zap.Int("count", len(scores)),
zap.Int("limit", params.Limit))
return scores, nil
}
// calculateRecommendationScore calcule un score de recommandation pour une playlist
// MIGRATION UUID: userID migré vers uuid.UUID
func (s *PlaylistRecommendationService) calculateRecommendationScore(
ctx context.Context,
playlist *models.Playlist,
userID uuid.UUID,
followedPlaylists []*models.Playlist,
) *RecommendationScore {
score := 0.0
reasons := make([]string, 0)
// 1. Score basé sur la similarité avec les playlists suivies (poids: 0.5)
if len(followedPlaylists) > 0 {
similarityScore := s.calculateSimilarityScore(ctx, playlist, followedPlaylists)
score += similarityScore * 0.5
if similarityScore > 0.1 {
reasons = append(reasons, fmt.Sprintf("Similaire aux playlists suivies (%.2f)", similarityScore))
}
}
// 2. Score basé sur la popularité (nombre de followers) (poids: 0.2)
popularityScore := s.calculatePopularityScore(playlist)
score += popularityScore * 0.2
if popularityScore > 0.1 {
reasons = append(reasons, fmt.Sprintf("Populaire (%.2f followers)", float64(playlist.FollowerCount)))
}
// 3. Score basé sur le nombre de tracks (poids: 0.1)
trackCountScore := s.calculateTrackCountScore(playlist)
score += trackCountScore * 0.1
if trackCountScore > 0.1 {
reasons = append(reasons, fmt.Sprintf("Contenu riche (%d tracks)", playlist.TrackCount))
}
// 4. Score basé sur la récence (poids: 0.2)
recencyScore := s.calculateRecencyScore(playlist)
score += recencyScore * 0.2
if recencyScore > 0.1 {
reasons = append(reasons, "Récente")
}
// Normaliser le score entre 0 et 1
normalizedScore := math.Min(score, 1.0)
reason := "Recommandation basée sur plusieurs facteurs"
if len(reasons) > 0 {
reason = reasons[0] // Prendre la raison principale
}
return &RecommendationScore{
Playlist: playlist,
Score: normalizedScore,
Reason: reason,
}
}
// calculateSimilarityScore calcule un score de similarité basé sur les tracks communs
func (s *PlaylistRecommendationService) calculateSimilarityScore(
ctx context.Context,
playlist *models.Playlist,
followedPlaylists []*models.Playlist,
) float64 {
if playlist.Tracks == nil || len(playlist.Tracks) == 0 {
return 0.0
}
// Récupérer les tracks de la playlist cible
targetTrackIDs := make(map[uuid.UUID]bool)
for _, pt := range playlist.Tracks {
targetTrackIDs[pt.TrackID] = true
}
if len(targetTrackIDs) == 0 {
return 0.0
}
// Calculer la similarité avec chaque playlist suivie
totalSimilarity := 0.0
validComparisons := 0
for _, followed := range followedPlaylists {
if followed.Tracks == nil || len(followed.Tracks) == 0 {
continue
}
// Récupérer les tracks de la playlist suivie
followedTrackIDs := make(map[uuid.UUID]bool)
for _, pt := range followed.Tracks {
followedTrackIDs[pt.TrackID] = true
}
if len(followedTrackIDs) == 0 {
continue
}
// Calculer l'intersection (tracks communs)
commonTracks := 0
for trackID := range targetTrackIDs {
if followedTrackIDs[trackID] {
commonTracks++
}
}
// Calculer le coefficient de Jaccard (similarité)
unionSize := len(targetTrackIDs) + len(followedTrackIDs) - commonTracks
if unionSize > 0 {
similarity := float64(commonTracks) / float64(unionSize)
totalSimilarity += similarity
validComparisons++
}
}
if validComparisons == 0 {
return 0.0
}
// Moyenne des similarités
return totalSimilarity / float64(validComparisons)
}
// calculatePopularityScore calcule un score basé sur la popularité (nombre de followers)
func (s *PlaylistRecommendationService) calculatePopularityScore(playlist *models.Playlist) float64 {
// Normaliser le nombre de followers (logarithmique pour éviter que les très grandes valeurs dominent)
// On considère qu'un playlist avec 100+ followers est très populaire
maxFollowers := 100.0
followers := float64(playlist.FollowerCount)
if followers <= 0 {
return 0.0
}
// Utiliser une fonction logarithmique pour normaliser
normalized := math.Log10(followers+1) / math.Log10(maxFollowers+1)
return math.Min(normalized, 1.0)
}
// calculateTrackCountScore calcule un score basé sur le nombre de tracks
func (s *PlaylistRecommendationService) calculateTrackCountScore(playlist *models.Playlist) float64 {
// On considère qu'une playlist avec 20+ tracks a un bon contenu
optimalTrackCount := 20.0
trackCount := float64(playlist.TrackCount)
if trackCount <= 0 {
return 0.0
}
// Score qui augmente jusqu'à optimalTrackCount, puis se stabilise
if trackCount >= optimalTrackCount {
return 1.0
}
return trackCount / optimalTrackCount
}
// calculateRecencyScore calcule un score basé sur la récence de la playlist
func (s *PlaylistRecommendationService) calculateRecencyScore(playlist *models.Playlist) float64 {
if playlist.CreatedAt.IsZero() {
return 0.0
}
// Calculer l'âge en jours
ageInDays := float64(time.Since(playlist.CreatedAt).Hours() / 24)
// Si UpdatedAt est plus récent que CreatedAt, utiliser UpdatedAt
if !playlist.UpdatedAt.IsZero() && playlist.UpdatedAt.After(playlist.CreatedAt) {
ageInDays = float64(time.Since(playlist.UpdatedAt).Hours() / 24)
}
// Les playlists créées/mises à jour dans les 30 derniers jours ont un score élevé
maxAge := 30.0
age := ageInDays
if age <= 0 {
return 1.0 // Très récente
}
if age >= maxAge {
return 0.0 // Ancienne
}
// Score qui diminue linéairement avec l'âge
return 1.0 - (age / maxAge)
}
// isPlaylistFollowed vérifie si une playlist est dans la liste des playlists suivies
func (s *PlaylistRecommendationService) isPlaylistFollowed(playlistID uuid.UUID, followedPlaylists []*models.Playlist) bool {
for _, followed := range followedPlaylists {
if followed.ID == playlistID {
return true
}
}
return false
}