package services import ( "context" "fmt" "github.com/google/uuid" "time" "veza-backend-api/internal/models" "go.uber.org/zap" "gorm.io/gorm" ) // PlaybackComparisonService gère la comparaison des analytics de lecture // T0373: Create Playback Analytics Comparison Service type PlaybackComparisonService struct { db *gorm.DB logger *zap.Logger } // NewPlaybackComparisonService crée un nouveau service de comparaison d'analytics func NewPlaybackComparisonService(db *gorm.DB, logger *zap.Logger) *PlaybackComparisonService { if logger == nil { logger = zap.NewNop() } return &PlaybackComparisonService{ db: db, logger: logger, } } // ComparisonResult représente le résultat d'une comparaison type ComparisonResult struct { Period1 *PlaybackStats `json:"period1"` Period2 *PlaybackStats `json:"period2"` Difference *StatsDifference `json:"difference"` PercentageChange *PercentageChange `json:"percentage_change"` } // StatsDifference représente la différence absolue entre deux statistiques type StatsDifference struct { TotalSessions int64 `json:"total_sessions"` TotalPlayTime int64 `json:"total_play_time"` // seconds AveragePlayTime float64 `json:"average_play_time"` // seconds TotalPauses int64 `json:"total_pauses"` AveragePauses float64 `json:"average_pauses"` TotalSeeks int64 `json:"total_seeks"` AverageSeeks float64 `json:"average_seeks"` AverageCompletion float64 `json:"average_completion"` // percentage CompletionRate float64 `json:"completion_rate"` // percentage } // PercentageChange représente le changement en pourcentage entre deux statistiques type PercentageChange struct { TotalSessions float64 `json:"total_sessions"` // % TotalPlayTime float64 `json:"total_play_time"` // % AveragePlayTime float64 `json:"average_play_time"` // % TotalPauses float64 `json:"total_pauses"` // % AveragePauses float64 `json:"average_pauses"` // % TotalSeeks float64 `json:"total_seeks"` // % AverageSeeks float64 `json:"average_seeks"` // % AverageCompletion float64 `json:"average_completion"` // % CompletionRate float64 `json:"completion_rate"` // % } // getPeriodDates retourne les dates de début et de fin pour une période donnée func (s *PlaybackComparisonService) getPeriodDates(period string) (time.Time, time.Time, error) { now := time.Now() var startDate, endDate time.Time switch period { case "today": startDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) endDate = now case "week": startDate = now.AddDate(0, 0, -7) endDate = now case "month": startDate = now.AddDate(0, 0, -30) endDate = now case "year": startDate = now.AddDate(-1, 0, 0) endDate = now default: return time.Time{}, time.Time{}, fmt.Errorf("invalid period: %s (must be today, week, month, or year)", period) } return startDate, endDate, nil } // getStatsForPeriod récupère les statistiques pour une période donnée func (s *PlaybackComparisonService) getStatsForPeriod(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time) (*PlaybackStats, error) { var stats PlaybackStats // Total sessions if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND created_at >= ? AND created_at <= ?", trackID, startDate, endDate). Count(&stats.TotalSessions).Error; err != nil { return nil, fmt.Errorf("failed to count sessions: %w", err) } if stats.TotalSessions == 0 { return &stats, nil } // Total play time var totalPlayTime int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND created_at >= ? AND created_at <= ?", trackID, startDate, endDate). Select("COALESCE(SUM(play_time), 0)").Scan(&totalPlayTime).Error; err != nil { return nil, fmt.Errorf("failed to calculate total play time: %w", err) } stats.TotalPlayTime = totalPlayTime stats.AveragePlayTime = float64(totalPlayTime) / float64(stats.TotalSessions) // Total pauses var totalPauses int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND created_at >= ? AND created_at <= ?", trackID, startDate, endDate). Select("COALESCE(SUM(pause_count), 0)").Scan(&totalPauses).Error; err != nil { return nil, fmt.Errorf("failed to calculate total pauses: %w", err) } stats.TotalPauses = totalPauses stats.AveragePauses = float64(totalPauses) / float64(stats.TotalSessions) // Total seeks var totalSeeks int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND created_at >= ? AND created_at <= ?", trackID, startDate, endDate). Select("COALESCE(SUM(seek_count), 0)").Scan(&totalSeeks).Error; err != nil { return nil, fmt.Errorf("failed to calculate total seeks: %w", err) } stats.TotalSeeks = totalSeeks stats.AverageSeeks = float64(totalSeeks) / float64(stats.TotalSessions) // Average completion rate var avgCompletion float64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND created_at >= ? AND created_at <= ?", trackID, startDate, endDate). Select("COALESCE(AVG(completion_rate), 0)").Scan(&avgCompletion).Error; err != nil { return nil, fmt.Errorf("failed to calculate average completion: %w", err) } stats.AverageCompletion = avgCompletion // Completion rate (sessions with >90% completion) var completedSessions int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND created_at >= ? AND created_at <= ? AND completion_rate >= ?", trackID, startDate, endDate, 90.0). Count(&completedSessions).Error; err != nil { return nil, fmt.Errorf("failed to count completed sessions: %w", err) } if stats.TotalSessions > 0 { stats.CompletionRate = float64(completedSessions) / float64(stats.TotalSessions) * 100.0 } return &stats, nil } // calculateDifference calcule la différence absolue entre deux statistiques func (s *PlaybackComparisonService) calculateDifference(stats1, stats2 *PlaybackStats) *StatsDifference { return &StatsDifference{ TotalSessions: stats2.TotalSessions - stats1.TotalSessions, TotalPlayTime: stats2.TotalPlayTime - stats1.TotalPlayTime, AveragePlayTime: stats2.AveragePlayTime - stats1.AveragePlayTime, TotalPauses: stats2.TotalPauses - stats1.TotalPauses, AveragePauses: stats2.AveragePauses - stats1.AveragePauses, TotalSeeks: stats2.TotalSeeks - stats1.TotalSeeks, AverageSeeks: stats2.AverageSeeks - stats1.AverageSeeks, AverageCompletion: stats2.AverageCompletion - stats1.AverageCompletion, CompletionRate: stats2.CompletionRate - stats1.CompletionRate, } } // calculatePercentageChange calcule le changement en pourcentage entre deux statistiques func (s *PlaybackComparisonService) calculatePercentageChange(stats1, stats2 *PlaybackStats) *PercentageChange { change := &PercentageChange{} // Total sessions if stats1.TotalSessions > 0 { change.TotalSessions = float64(stats2.TotalSessions-stats1.TotalSessions) / float64(stats1.TotalSessions) * 100.0 } else if stats2.TotalSessions > 0 { change.TotalSessions = 100.0 // 100% increase from 0 } // Total play time if stats1.TotalPlayTime > 0 { change.TotalPlayTime = float64(stats2.TotalPlayTime-stats1.TotalPlayTime) / float64(stats1.TotalPlayTime) * 100.0 } else if stats2.TotalPlayTime > 0 { change.TotalPlayTime = 100.0 } // Average play time if stats1.AveragePlayTime > 0 { change.AveragePlayTime = (stats2.AveragePlayTime - stats1.AveragePlayTime) / stats1.AveragePlayTime * 100.0 } else if stats2.AveragePlayTime > 0 { change.AveragePlayTime = 100.0 } // Total pauses if stats1.TotalPauses > 0 { change.TotalPauses = float64(stats2.TotalPauses-stats1.TotalPauses) / float64(stats1.TotalPauses) * 100.0 } else if stats2.TotalPauses > 0 { change.TotalPauses = 100.0 } // Average pauses if stats1.AveragePauses > 0 { change.AveragePauses = (stats2.AveragePauses - stats1.AveragePauses) / stats1.AveragePauses * 100.0 } else if stats2.AveragePauses > 0 { change.AveragePauses = 100.0 } // Total seeks if stats1.TotalSeeks > 0 { change.TotalSeeks = float64(stats2.TotalSeeks-stats1.TotalSeeks) / float64(stats1.TotalSeeks) * 100.0 } else if stats2.TotalSeeks > 0 { change.TotalSeeks = 100.0 } // Average seeks if stats1.AverageSeeks > 0 { change.AverageSeeks = (stats2.AverageSeeks - stats1.AverageSeeks) / stats1.AverageSeeks * 100.0 } else if stats2.AverageSeeks > 0 { change.AverageSeeks = 100.0 } // Average completion if stats1.AverageCompletion > 0 { change.AverageCompletion = (stats2.AverageCompletion - stats1.AverageCompletion) / stats1.AverageCompletion * 100.0 } else if stats2.AverageCompletion > 0 { change.AverageCompletion = 100.0 } // Completion rate if stats1.CompletionRate > 0 { change.CompletionRate = (stats2.CompletionRate - stats1.CompletionRate) / stats1.CompletionRate * 100.0 } else if stats2.CompletionRate > 0 { change.CompletionRate = 100.0 } return change } // ComparePeriods compare les analytics entre deux périodes pour un track // T0373: Create Playback Analytics Comparison Service func (s *PlaybackComparisonService) ComparePeriods(ctx context.Context, trackID uuid.UUID, period1, period2 string) (*ComparisonResult, error) { if trackID == uuid.Nil { return nil, fmt.Errorf("invalid track ID: %s", trackID) } // Vérifier que le track existe var track models.Track if err := s.db.WithContext(ctx).First(&track, trackID).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("track not found: %d", trackID) } return nil, fmt.Errorf("failed to get track: %w", err) } // Obtenir les dates pour chaque période startDate1, endDate1, err := s.getPeriodDates(period1) if err != nil { return nil, fmt.Errorf("invalid period1: %w", err) } startDate2, endDate2, err := s.getPeriodDates(period2) if err != nil { return nil, fmt.Errorf("invalid period2: %w", err) } // Récupérer les statistiques pour chaque période stats1, err := s.getStatsForPeriod(ctx, trackID, startDate1, endDate1) if err != nil { return nil, fmt.Errorf("failed to get stats for period1: %w", err) } stats2, err := s.getStatsForPeriod(ctx, trackID, startDate2, endDate2) if err != nil { return nil, fmt.Errorf("failed to get stats for period2: %w", err) } // Calculer les différences difference := s.calculateDifference(stats1, stats2) percentageChange := s.calculatePercentageChange(stats1, stats2) result := &ComparisonResult{ Period1: stats1, Period2: stats2, Difference: difference, PercentageChange: percentageChange, } s.logger.Info("Compared playback analytics periods", zap.String("track_id", trackID.String()), zap.String("period1", period1), zap.String("period2", period2)) return result, nil } // CompareTracks compare les analytics entre deux tracks // T0373: Create Playback Analytics Comparison Service func (s *PlaybackComparisonService) CompareTracks(ctx context.Context, trackID1, trackID2 uuid.UUID, startDate, endDate time.Time) (*ComparisonResult, error) { if trackID1 == uuid.Nil { return nil, fmt.Errorf("invalid track ID 1: %s", trackID1) } if trackID2 == uuid.Nil { return nil, fmt.Errorf("invalid track ID 2: %s", trackID2) } // Vérifier que les tracks existent var track1, track2 models.Track if err := s.db.WithContext(ctx).First(&track1, trackID1).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("track not found: %s", trackID1) } return nil, fmt.Errorf("failed to get track 1: %w", err) } if err := s.db.WithContext(ctx).First(&track2, trackID2).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("track not found: %s", trackID2) } return nil, fmt.Errorf("failed to get track 2: %w", err) } // Récupérer les statistiques pour chaque track stats1, err := s.getStatsForPeriod(ctx, trackID1, startDate, endDate) if err != nil { return nil, fmt.Errorf("failed to get stats for track 1: %w", err) } stats2, err := s.getStatsForPeriod(ctx, trackID2, startDate, endDate) if err != nil { return nil, fmt.Errorf("failed to get stats for track 2: %w", err) } // Calculer les différences difference := s.calculateDifference(stats1, stats2) percentageChange := s.calculatePercentageChange(stats1, stats2) result := &ComparisonResult{ Period1: stats1, Period2: stats2, Difference: difference, PercentageChange: percentageChange, } s.logger.Info("Compared playback analytics tracks", zap.String("track_id1", trackID1.String()), zap.String("track_id2", trackID2.String())) return result, nil } // CompareUsers compare les analytics entre deux users pour un track // T0373: Create Playback Analytics Comparison Service func (s *PlaybackComparisonService) CompareUsers(ctx context.Context, trackID uuid.UUID, userID1, userID2 uuid.UUID, startDate, endDate time.Time) (*ComparisonResult, error) { if trackID == uuid.Nil { return nil, fmt.Errorf("invalid track ID: %s", trackID) } if userID1 == uuid.Nil { return nil, fmt.Errorf("invalid user ID 1: nil UUID") } if userID2 == uuid.Nil { return nil, fmt.Errorf("invalid user ID 2: nil UUID") } // Vérifier que le track existe var track models.Track if err := s.db.WithContext(ctx).First(&track, trackID).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("track not found: %d", trackID) } return nil, fmt.Errorf("failed to get track: %w", err) } // Vérifier que les users existent var user1, user2 models.User if err := s.db.WithContext(ctx).First(&user1, userID1).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("user not found: %s", userID1) } return nil, fmt.Errorf("failed to get user 1: %w", err) } if err := s.db.WithContext(ctx).First(&user2, userID2).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("user not found: %s", userID2) } return nil, fmt.Errorf("failed to get user 2: %w", err) } // Récupérer les statistiques pour chaque user stats1, err := s.getStatsForUser(ctx, trackID, userID1, startDate, endDate) if err != nil { return nil, fmt.Errorf("failed to get stats for user 1: %w", err) } stats2, err := s.getStatsForUser(ctx, trackID, userID2, startDate, endDate) if err != nil { return nil, fmt.Errorf("failed to get stats for user 2: %w", err) } // Calculer les différences difference := s.calculateDifference(stats1, stats2) percentageChange := s.calculatePercentageChange(stats1, stats2) result := &ComparisonResult{ Period1: stats1, Period2: stats2, Difference: difference, PercentageChange: percentageChange, } s.logger.Info("Compared playback analytics users", zap.String("track_id", trackID.String()), zap.String("user_id1", userID1.String()), zap.String("user_id2", userID2.String())) return result, nil } // getStatsForUser récupère les statistiques pour un utilisateur spécifique // MIGRATION UUID: userID en uuid.UUID, trackID reste int64 func (s *PlaybackComparisonService) getStatsForUser(ctx context.Context, trackID uuid.UUID, userID uuid.UUID, startDate, endDate time.Time) (*PlaybackStats, error) { var stats PlaybackStats // Total sessions if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND user_id = ? AND created_at >= ? AND created_at <= ?", trackID, userID, startDate, endDate). Count(&stats.TotalSessions).Error; err != nil { return nil, fmt.Errorf("failed to count sessions: %w", err) } if stats.TotalSessions == 0 { return &stats, nil } // Total play time var totalPlayTime int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND user_id = ? AND created_at >= ? AND created_at <= ?", trackID, userID, startDate, endDate). Select("COALESCE(SUM(play_time), 0)").Scan(&totalPlayTime).Error; err != nil { return nil, fmt.Errorf("failed to calculate total play time: %w", err) } stats.TotalPlayTime = totalPlayTime stats.AveragePlayTime = float64(totalPlayTime) / float64(stats.TotalSessions) // Total pauses var totalPauses int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND user_id = ? AND created_at >= ? AND created_at <= ?", trackID, userID, startDate, endDate). Select("COALESCE(SUM(pause_count), 0)").Scan(&totalPauses).Error; err != nil { return nil, fmt.Errorf("failed to calculate total pauses: %w", err) } stats.TotalPauses = totalPauses stats.AveragePauses = float64(totalPauses) / float64(stats.TotalSessions) // Total seeks var totalSeeks int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND user_id = ? AND created_at >= ? AND created_at <= ?", trackID, userID, startDate, endDate). Select("COALESCE(SUM(seek_count), 0)").Scan(&totalSeeks).Error; err != nil { return nil, fmt.Errorf("failed to calculate total seeks: %w", err) } stats.TotalSeeks = totalSeeks stats.AverageSeeks = float64(totalSeeks) / float64(stats.TotalSessions) // Average completion rate var avgCompletion float64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND user_id = ? AND created_at >= ? AND created_at <= ?", trackID, userID, startDate, endDate). Select("COALESCE(AVG(completion_rate), 0)").Scan(&avgCompletion).Error; err != nil { return nil, fmt.Errorf("failed to calculate average completion: %w", err) } stats.AverageCompletion = avgCompletion // Completion rate (sessions with >90% completion) var completedSessions int64 if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ? AND user_id = ? AND created_at >= ? AND created_at <= ? AND completion_rate >= ?", trackID, userID, startDate, endDate, 90.0). Count(&completedSessions).Error; err != nil { return nil, fmt.Errorf("failed to count completed sessions: %w", err) } if stats.TotalSessions > 0 { stats.CompletionRate = float64(completedSessions) / float64(stats.TotalSessions) * 100.0 } return &stats, nil }