veza/veza-backend-api/internal/services/track_recommendation_service.go
2026-03-05 23:03:43 +01:00

450 lines
12 KiB
Go

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
}