450 lines
12 KiB
Go
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
|
|
}
|