veza/veza-backend-api/internal/services/playback_comparison_service.go

490 lines
18 KiB
Go

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
}