382 lines
12 KiB
Go
382 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"github.com/google/uuid"
|
|
"time"
|
|
|
|
"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, track.Duration, 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, trackDuration int, 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,
|
|
}
|
|
}
|