490 lines
18 KiB
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
|
|
}
|