338 lines
10 KiB
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, ¶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,
|
|
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
|
|
}
|