package services import ( "context" "fmt" "time" "github.com/google/uuid" "veza-backend-api/internal/models" "go.uber.org/zap" "gorm.io/gorm" ) // PlaybackRetentionService gère l'analyse de rétention des analytics de lecture // T0375: Create Playback Analytics Retention Analysis type PlaybackRetentionService struct { db *gorm.DB logger *zap.Logger } // NewPlaybackRetentionService crée un nouveau service d'analyse de rétention func NewPlaybackRetentionService(db *gorm.DB, logger *zap.Logger) *PlaybackRetentionService { if logger == nil { logger = zap.NewNop() } return &PlaybackRetentionService{ db: db, logger: logger, } } // SegmentRetention représente la rétention pour un segment du track type SegmentRetention struct { SegmentStart float64 `json:"segment_start"` // Pourcentage de début du segment (0-100) SegmentEnd float64 `json:"segment_end"` // Pourcentage de fin du segment (0-100) RetentionRate float64 `json:"retention_rate"` // Pourcentage d'utilisateurs qui atteignent ce segment ExitCount int64 `json:"exit_count"` // Nombre d'utilisateurs qui sortent dans ce segment ExitRate float64 `json:"exit_rate"` // Pourcentage d'utilisateurs qui sortent dans ce segment AveragePlayTime float64 `json:"average_play_time"` // Temps de lecture moyen dans ce segment (secondes) } // ExitPoint représente un point de sortie identifié type ExitPoint struct { SegmentStart float64 `json:"segment_start"` // Pourcentage de début du segment SegmentEnd float64 `json:"segment_end"` // Pourcentage de fin du segment ExitCount int64 `json:"exit_count"` // Nombre de sorties ExitRate float64 `json:"exit_rate"` // Taux de sortie (%) TotalSessions int64 `json:"total_sessions"` // Nombre total de sessions AveragePlayTime float64 `json:"average_play_time"` // Temps de lecture moyen avant sortie } // EngagementMetrics représente les métriques d'engagement type EngagementMetrics struct { OverallRetentionRate float64 `json:"overall_retention_rate"` // Taux de rétention global (%) EngagementScore float64 `json:"engagement_score"` // Score d'engagement (0-100) AverageCompletion float64 `json:"average_completion"` // Taux de complétion moyen (%) HighEngagementRate float64 `json:"high_engagement_rate"` // Pourcentage de sessions avec engagement élevé (>75% completion) LowEngagementRate float64 `json:"low_engagement_rate"` // Pourcentage de sessions avec engagement faible (<25% completion) AveragePauses float64 `json:"average_pauses"` // Nombre moyen de pauses AverageSeeks float64 `json:"average_seeks"` // Nombre moyen de seeks } // RetentionAnalysisResult représente le résultat complet de l'analyse de rétention type RetentionAnalysisResult struct { TrackID uuid.UUID `json:"track_id"` TrackDuration int `json:"track_duration"` // secondes TotalSessions int64 `json:"total_sessions"` SegmentRetentions []SegmentRetention `json:"segment_retentions"` ExitPoints []ExitPoint `json:"exit_points"` EngagementMetrics EngagementMetrics `json:"engagement_metrics"` AnalyzedAt time.Time `json:"analyzed_at"` } // AnalyzeRetention analyse la rétention pour un track // T0375: Create Playback Analytics Retention Analysis func (s *PlaybackRetentionService) AnalyzeRetention(ctx context.Context, trackID uuid.UUID, segmentCount int) (*RetentionAnalysisResult, error) { if trackID == uuid.Nil { return nil, fmt.Errorf("invalid track ID: %s", trackID) } if segmentCount <= 0 { segmentCount = 10 // Par défaut, 10 segments } if segmentCount > 100 { segmentCount = 100 // Maximum 100 segments } // Vérifier que le track existe var track models.Track if err := s.db.WithContext(ctx).First(&track, "id = ?", trackID).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, fmt.Errorf("track not found: %s", trackID) } return nil, fmt.Errorf("failed to get track: %w", err) } if track.Duration <= 0 { return nil, fmt.Errorf("track has invalid duration: %d", track.Duration) } // Récupérer toutes les analytics pour ce track var analytics []models.PlaybackAnalytics if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}). Where("track_id = ?", trackID). Find(&analytics).Error; err != nil { return nil, fmt.Errorf("failed to get analytics: %w", err) } // Initialiser les segments même s'il n'y a pas de sessions segmentRetentions := make([]SegmentRetention, segmentCount) segmentSize := 100.0 / float64(segmentCount) for i := 0; i < segmentCount; i++ { segmentRetentions[i] = SegmentRetention{ SegmentStart: float64(i) * segmentSize, SegmentEnd: float64(i)*segmentSize + segmentSize, RetentionRate: 0.0, ExitCount: 0, ExitRate: 0.0, AveragePlayTime: 0.0, } } if len(analytics) == 0 { // Retourner un résultat avec segments initialisés mais vides return &RetentionAnalysisResult{ TrackID: trackID, TrackDuration: track.Duration, TotalSessions: 0, SegmentRetentions: segmentRetentions, ExitPoints: []ExitPoint{}, EngagementMetrics: EngagementMetrics{}, AnalyzedAt: time.Now(), }, nil } // Calculer la rétention par segment segmentRetentions = s.calculateSegmentRetention(analytics, track.Duration, segmentCount) // Identifier les points de sortie exitPoints := s.identifyExitPoints(analytics, segmentCount) // Analyser l'engagement engagementMetrics := s.analyzeEngagement(analytics) result := &RetentionAnalysisResult{ TrackID: trackID, TrackDuration: track.Duration, TotalSessions: int64(len(analytics)), SegmentRetentions: segmentRetentions, ExitPoints: exitPoints, EngagementMetrics: engagementMetrics, AnalyzedAt: time.Now(), } s.logger.Info("Analyzed playback retention", zap.String("track_id", trackID.String()), zap.Int("total_sessions", len(analytics)), zap.Int("segments", segmentCount)) return result, nil } // calculateSegmentRetention calcule la rétention par segment func (s *PlaybackRetentionService) calculateSegmentRetention(analytics []models.PlaybackAnalytics, trackDuration int, segmentCount int) []SegmentRetention { segmentSize := 100.0 / float64(segmentCount) retentions := make([]SegmentRetention, segmentCount) totalSessions := float64(len(analytics)) // Pour chaque segment for i := 0; i < segmentCount; i++ { segmentStart := float64(i) * segmentSize segmentEnd := segmentStart + segmentSize // Calculer le temps de lecture minimum pour atteindre ce segment segmentStartSeconds := (segmentStart / 100.0) * float64(trackDuration) segmentEndSeconds := (segmentEnd / 100.0) * float64(trackDuration) // Compter les sessions qui atteignent ce segment var reachedCount int64 var exitCount int64 var totalPlayTimeInSegment float64 var sessionsInSegment int64 for _, a := range analytics { playTimeSeconds := float64(a.PlayTime) // Vérifier si la session atteint le début du segment if playTimeSeconds >= segmentStartSeconds { reachedCount++ // Vérifier si la session sort dans ce segment if playTimeSeconds >= segmentStartSeconds && playTimeSeconds < segmentEndSeconds { exitCount++ } // Calculer le temps de lecture dans ce segment if playTimeSeconds >= segmentStartSeconds { segmentPlayTime := playTimeSeconds - segmentStartSeconds if segmentPlayTime > segmentSize/100.0*float64(trackDuration) { segmentPlayTime = segmentSize / 100.0 * float64(trackDuration) } totalPlayTimeInSegment += segmentPlayTime sessionsInSegment++ } } } // Calculer les taux retentionRate := 0.0 if totalSessions > 0 { retentionRate = float64(reachedCount) / totalSessions * 100.0 } exitRate := 0.0 if reachedCount > 0 { exitRate = float64(exitCount) / float64(reachedCount) * 100.0 } averagePlayTime := 0.0 if sessionsInSegment > 0 { averagePlayTime = totalPlayTimeInSegment / float64(sessionsInSegment) } retentions[i] = SegmentRetention{ SegmentStart: segmentStart, SegmentEnd: segmentEnd, RetentionRate: retentionRate, ExitCount: exitCount, ExitRate: exitRate, AveragePlayTime: averagePlayTime, } } return retentions } // identifyExitPoints identifie les points de sortie principaux func (s *PlaybackRetentionService) identifyExitPoints(analytics []models.PlaybackAnalytics, segmentCount int) []ExitPoint { segmentSize := 100.0 / float64(segmentCount) exitPointsMap := make(map[int]*ExitPoint) totalSessions := int64(len(analytics)) // Pour chaque session, identifier le segment où elle sort for _, a := range analytics { playTimeSeconds := float64(a.PlayTime) _ = playTimeSeconds // Utilisé pour les calculs futurs si nécessaire // Déterminer dans quel segment la session se termine segmentIndex := int((a.CompletionRate / 100.0) * float64(segmentCount)) if segmentIndex >= segmentCount { segmentIndex = segmentCount - 1 } if exitPointsMap[segmentIndex] == nil { segmentStart := float64(segmentIndex) * segmentSize segmentEnd := segmentStart + segmentSize exitPointsMap[segmentIndex] = &ExitPoint{ SegmentStart: segmentStart, SegmentEnd: segmentEnd, ExitCount: 0, TotalSessions: totalSessions, AveragePlayTime: 0.0, } } exitPoint := exitPointsMap[segmentIndex] exitPoint.ExitCount++ exitPoint.AveragePlayTime += playTimeSeconds } // Calculer les moyennes et taux var exitPoints []ExitPoint for _, ep := range exitPointsMap { if ep.ExitCount > 0 { ep.AveragePlayTime = ep.AveragePlayTime / float64(ep.ExitCount) if ep.TotalSessions > 0 { ep.ExitRate = float64(ep.ExitCount) / float64(ep.TotalSessions) * 100.0 } exitPoints = append(exitPoints, *ep) } } // Trier par taux de sortie décroissant for i := 0; i < len(exitPoints)-1; i++ { for j := i + 1; j < len(exitPoints); j++ { if exitPoints[i].ExitRate < exitPoints[j].ExitRate { exitPoints[i], exitPoints[j] = exitPoints[j], exitPoints[i] } } } // Retourner les 5 principaux points de sortie maxExitPoints := 5 if len(exitPoints) < maxExitPoints { maxExitPoints = len(exitPoints) } return exitPoints[:maxExitPoints] } // analyzeEngagement analyse les métriques d'engagement func (s *PlaybackRetentionService) analyzeEngagement(analytics []models.PlaybackAnalytics) EngagementMetrics { if len(analytics) == 0 { return EngagementMetrics{} } var totalCompletion float64 var highEngagementCount int64 var lowEngagementCount int64 var totalPauses int64 var totalSeeks int64 for _, a := range analytics { totalCompletion += a.CompletionRate if a.CompletionRate >= 75.0 { highEngagementCount++ } if a.CompletionRate < 25.0 { lowEngagementCount++ } totalPauses += int64(a.PauseCount) totalSeeks += int64(a.SeekCount) } totalSessions := float64(len(analytics)) // Calculer les métriques averageCompletion := totalCompletion / totalSessions overallRetentionRate := averageCompletion // Le taux de rétention global est le taux de complétion moyen highEngagementRate := float64(highEngagementCount) / totalSessions * 100.0 lowEngagementRate := float64(lowEngagementCount) / totalSessions * 100.0 averagePauses := float64(totalPauses) / totalSessions averageSeeks := float64(totalSeeks) / totalSessions // Calculer le score d'engagement (0-100) // Basé sur: completion rate (50%), pauses (25%), seeks (25%) // Moins de pauses et seeks = meilleur engagement engagementScore := averageCompletion * 0.5 // Normaliser les pauses (0-10 pauses = 0-25 points) pauseScore := 25.0 if averagePauses > 0 { pauseScore = 25.0 - (averagePauses / 10.0 * 25.0) if pauseScore < 0 { pauseScore = 0 } } engagementScore += pauseScore // Normaliser les seeks (0-5 seeks = 0-25 points) seekScore := 25.0 if averageSeeks > 0 { seekScore = 25.0 - (averageSeeks / 5.0 * 25.0) if seekScore < 0 { seekScore = 0 } } engagementScore += seekScore // S'assurer que le score est entre 0 et 100 if engagementScore > 100.0 { engagementScore = 100.0 } if engagementScore < 0.0 { engagementScore = 0.0 } return EngagementMetrics{ OverallRetentionRate: overallRetentionRate, EngagementScore: engagementScore, AverageCompletion: averageCompletion, HighEngagementRate: highEngagementRate, LowEngagementRate: lowEngagementRate, AveragePauses: averagePauses, AverageSeeks: averageSeeks, } }