package services import ( "context" "fmt" "math" "sort" "time" "veza-backend-api/internal/models" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" ) // TrackRecommendationService provides ML-based recommendations for tracks // BE-SVC-007: Implement recommendation engine type TrackRecommendationService struct { db *gorm.DB logger *zap.Logger } // NewTrackRecommendationService creates a new track recommendation service func NewTrackRecommendationService(db *gorm.DB, logger *zap.Logger) *TrackRecommendationService { if logger == nil { logger = zap.NewNop() } return &TrackRecommendationService{ db: db, logger: logger, } } // TrackRecommendationParams represents parameters for track recommendations type TrackRecommendationParams struct { UserID uuid.UUID // User to get recommendations for Limit int // Number of recommendations (default: 20, max: 100) MinScore float64 // Minimum recommendation score (default: 0.1) SeedTrackID *uuid.UUID // Optional seed track for content-based recommendations Genres []string // Optional genre filter ExcludeIDs []uuid.UUID // Track IDs to exclude from results } // TrackRecommendation represents a recommended track with score and reason type TrackRecommendation struct { Track *models.Track `json:"track"` Score float64 `json:"score"` Reason string `json:"reason"` } // GetRecommendations returns personalized track recommendations for a user func (s *TrackRecommendationService) GetRecommendations( ctx context.Context, params TrackRecommendationParams, ) ([]*TrackRecommendation, error) { // Validate and set defaults if params.Limit <= 0 { params.Limit = 20 } if params.Limit > 100 { params.Limit = 100 } if params.MinScore <= 0 { params.MinScore = 0.1 } // Get user's listening history userHistory, err := s.getUserListeningHistory(ctx, params.UserID) if err != nil { s.logger.Warn("Failed to get user listening history", zap.String("user_id", params.UserID.String()), zap.Error(err)) userHistory = []uuid.UUID{} } // Get user's liked tracks likedTracks, err := s.getUserLikedTracks(ctx, params.UserID) if err != nil { s.logger.Warn("Failed to get user liked tracks", zap.String("user_id", params.UserID.String()), zap.Error(err)) likedTracks = []uuid.UUID{} } // Combine history and likes for user preferences userPreferences := make(map[uuid.UUID]bool) for _, trackID := range userHistory { userPreferences[trackID] = true } for _, trackID := range likedTracks { userPreferences[trackID] = true } // Get all public tracks (excluding user's own tracks and excluded IDs) var tracks []models.Track query := s.db.WithContext(ctx). Model(&models.Track{}). Where("is_public = ? AND deleted_at IS NULL", true). Where("creator_id != ?", params.UserID) // Exclude specified track IDs if len(params.ExcludeIDs) > 0 { query = query.Where("id NOT IN ?", params.ExcludeIDs) } // Apply genre filter if provided if len(params.Genres) > 0 { query = query.Where("LOWER(genre) IN ?", params.Genres) } if err := query.Find(&tracks).Error; err != nil { return nil, fmt.Errorf("failed to get tracks: %w", err) } // Calculate recommendation scores for each track recommendations := make([]*TrackRecommendation, 0, len(tracks)) recommendationMap := make(map[uuid.UUID]*TrackRecommendation) for i := range tracks { track := &tracks[i] // Skip tracks already in user preferences if userPreferences[track.ID] { continue } // Calculate recommendation score using multiple algorithms score, reason := s.calculateRecommendationScore( ctx, track, params.UserID, userHistory, likedTracks, params.SeedTrackID, ) if score >= params.MinScore { recommendationMap[track.ID] = &TrackRecommendation{ Track: track, Score: score, Reason: reason, } } } // Convert map to slice for _, rec := range recommendationMap { recommendations = append(recommendations, rec) } // Sort by score (descending) sort.Slice(recommendations, func(i, j int) bool { return recommendations[i].Score > recommendations[j].Score }) // Limit results if len(recommendations) > params.Limit { recommendations = recommendations[:params.Limit] } s.logger.Info("Track recommendations generated", zap.String("user_id", params.UserID.String()), zap.Int("count", len(recommendations)), zap.Int("limit", params.Limit)) return recommendations, nil } // calculateRecommendationScore calculates a recommendation score using multiple algorithms func (s *TrackRecommendationService) calculateRecommendationScore( ctx context.Context, track *models.Track, userID uuid.UUID, userHistory []uuid.UUID, likedTracks []uuid.UUID, seedTrackID *uuid.UUID, ) (float64, string) { totalScore := 0.0 reasons := []string{} // 1. Collaborative Filtering (40% weight) // Find users with similar taste and recommend tracks they liked collabScore := s.calculateCollaborativeScore(ctx, track, userID, likedTracks) totalScore += collabScore * 0.4 if collabScore > 0.1 { reasons = append(reasons, fmt.Sprintf("Similar users like this (%.2f)", collabScore)) } // 2. Content-Based Filtering (30% weight) // Recommend tracks similar to user's liked tracks contentScore := s.calculateContentBasedScore(ctx, track, userHistory, likedTracks, seedTrackID) totalScore += contentScore * 0.3 if contentScore > 0.1 { reasons = append(reasons, fmt.Sprintf("Similar to your preferences (%.2f)", contentScore)) } // 3. Popularity Score (20% weight) // Recommend popular tracks popularityScore := s.calculatePopularityScore(track) totalScore += popularityScore * 0.2 if popularityScore > 0.1 { reasons = append(reasons, fmt.Sprintf("Popular track (%.2f)", popularityScore)) } // 4. Recency Score (10% weight) // Recommend recent tracks recencyScore := s.calculateRecencyScore(track) totalScore += recencyScore * 0.1 if recencyScore > 0.1 { reasons = append(reasons, "Recently uploaded") } // Normalize score to 0-1 range normalizedScore := math.Min(totalScore, 1.0) reason := "Recommended based on multiple factors" if len(reasons) > 0 { reason = reasons[0] // Use primary reason } return normalizedScore, reason } // calculateCollaborativeScore uses collaborative filtering to find tracks liked by similar users func (s *TrackRecommendationService) calculateCollaborativeScore( ctx context.Context, track *models.Track, userID uuid.UUID, userLikedTracks []uuid.UUID, ) float64 { if len(userLikedTracks) == 0 { return 0.0 } // Find users who liked this track var usersWhoLiked []uuid.UUID if err := s.db.WithContext(ctx). Model(&models.TrackLike{}). Where("track_id = ?", track.ID). Pluck("user_id", &usersWhoLiked).Error; err != nil { return 0.0 } if len(usersWhoLiked) == 0 { return 0.0 } // Calculate similarity: how many tracks do these users have in common with current user? // Use Jaccard similarity commonLikes := 0 for _, likedTrackID := range userLikedTracks { // Check if any of the users who liked this track also liked the user's liked tracks var count int64 s.db.WithContext(ctx). Model(&models.TrackLike{}). Where("track_id = ? AND user_id IN ?", likedTrackID, usersWhoLiked). Count(&count) if count > 0 { commonLikes++ } } if len(userLikedTracks) == 0 { return 0.0 } // Jaccard similarity: intersection / union similarity := float64(commonLikes) / float64(len(userLikedTracks)) // Weight by number of users who liked this track (more users = more confidence) userCountWeight := math.Log10(float64(len(usersWhoLiked))+1) / math.Log10(100.0+1) userCountWeight = math.Min(userCountWeight, 1.0) return similarity * userCountWeight } // calculateContentBasedScore recommends tracks similar to user's preferences based on metadata func (s *TrackRecommendationService) calculateContentBasedScore( ctx context.Context, track *models.Track, userHistory []uuid.UUID, likedTracks []uuid.UUID, seedTrackID *uuid.UUID, ) float64 { // Get user's preferred tracks (history + likes) preferredTrackIDs := make(map[uuid.UUID]bool) for _, id := range userHistory { preferredTrackIDs[id] = true } for _, id := range likedTracks { preferredTrackIDs[id] = true } // If seed track is provided, use it if seedTrackID != nil { preferredTrackIDs[*seedTrackID] = true } if len(preferredTrackIDs) == 0 { return 0.0 } // Get preferred tracks metadata var preferredTracks []models.Track if err := s.db.WithContext(ctx). Model(&models.Track{}). Where("id IN ?", getKeys(preferredTrackIDs)). Find(&preferredTracks).Error; err != nil { return 0.0 } // Calculate similarity based on genre, artist, and other metadata totalSimilarity := 0.0 validComparisons := 0 for _, preferred := range preferredTracks { similarity := 0.0 // Genre match (weight: 0.4) if track.Genre != "" && preferred.Genre != "" { if track.Genre == preferred.Genre { similarity += 0.4 } } // Artist match (weight: 0.4) if track.Artist != "" && preferred.Artist != "" { if track.Artist == preferred.Artist { similarity += 0.4 } } // Year similarity (weight: 0.1) if track.Year > 0 && preferred.Year > 0 { yearDiff := math.Abs(float64(track.Year - preferred.Year)) if yearDiff <= 5 { similarity += 0.1 * (1.0 - yearDiff/5.0) } } // Format match (weight: 0.1) if track.Format != "" && preferred.Format != "" { if track.Format == preferred.Format { similarity += 0.1 } } totalSimilarity += similarity validComparisons++ } if validComparisons == 0 { return 0.0 } return totalSimilarity / float64(validComparisons) } // calculatePopularityScore calculates score based on track popularity func (s *TrackRecommendationService) calculatePopularityScore(track *models.Track) float64 { // Combine play_count and like_count for popularity playWeight := 0.6 likeWeight := 0.4 // Normalize play_count (logarithmic scale, max 10000 plays = 1.0) maxPlays := 10000.0 playScore := 0.0 if track.PlayCount > 0 { playScore = math.Log10(float64(track.PlayCount)+1) / math.Log10(maxPlays+1) playScore = math.Min(playScore, 1.0) } // Normalize like_count (logarithmic scale, max 1000 likes = 1.0) maxLikes := 1000.0 likeScore := 0.0 if track.LikeCount > 0 { likeScore = math.Log10(float64(track.LikeCount)+1) / math.Log10(maxLikes+1) likeScore = math.Min(likeScore, 1.0) } return (playScore * playWeight) + (likeScore * likeWeight) } // calculateRecencyScore calculates score based on track recency func (s *TrackRecommendationService) calculateRecencyScore(track *models.Track) float64 { if track.CreatedAt.IsZero() { return 0.0 } // Calculate age in days ageInDays := float64(time.Since(track.CreatedAt).Hours() / 24) // Tracks created in last 30 days get high score maxAge := 30.0 if ageInDays <= 0 { return 1.0 // Very recent } if ageInDays >= maxAge { return 0.0 // Old } // Score decreases linearly with age return 1.0 - (ageInDays / maxAge) } // getUserListeningHistory gets tracks the user has played func (s *TrackRecommendationService) getUserListeningHistory( ctx context.Context, userID uuid.UUID, ) ([]uuid.UUID, error) { var trackIDs []uuid.UUID // Get distinct track IDs from track_plays if err := s.db.WithContext(ctx). Model(&models.TrackPlay{}). Where("user_id = ? AND deleted_at IS NULL", userID). Distinct("track_id"). Pluck("track_id", &trackIDs).Error; err != nil { return nil, err } return trackIDs, nil } // getUserLikedTracks gets tracks the user has liked func (s *TrackRecommendationService) getUserLikedTracks( ctx context.Context, userID uuid.UUID, ) ([]uuid.UUID, error) { var trackIDs []uuid.UUID if err := s.db.WithContext(ctx). Model(&models.TrackLike{}). Where("user_id = ?", userID). Pluck("track_id", &trackIDs).Error; err != nil { return nil, err } return trackIDs, nil } // getKeys extracts keys from a map func getKeys(m map[uuid.UUID]bool) []uuid.UUID { keys := make([]uuid.UUID, 0, len(m)) for k := range m { keys = append(keys, k) } return keys }