veza/veza-backend-api/internal/services/playback_retention_service.go
2025-12-12 21:34:34 -05:00

383 lines
12 KiB
Go

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,
}
}