package services import ( "context" "fmt" "math" "sort" "time" "github.com/google/uuid" "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, ¶ms.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, _ 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( _ context.Context, playlist *models.Playlist, followedPlaylists []*models.Playlist, ) float64 { if 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 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 }