package services import ( "context" "fmt" "time" "veza-backend-api/internal/models" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" ) // PlaybackHeatmapService gère la génération de heatmap pour les analytics de lecture // T0376: Create Playback Analytics Heatmap Generation type PlaybackHeatmapService struct { db *gorm.DB logger *zap.Logger } // NewPlaybackHeatmapService crée un nouveau service de génération de heatmap func NewPlaybackHeatmapService(db *gorm.DB, logger *zap.Logger) *PlaybackHeatmapService { if logger == nil { logger = zap.NewNop() } return &PlaybackHeatmapService{ db: db, logger: logger, } } // HeatmapSegment représente un segment de la heatmap type HeatmapSegment struct { StartTime float64 `json:"start_time"` // Temps de début du segment (secondes) EndTime float64 `json:"end_time"` // Temps de fin du segment (secondes) ListenCount int64 `json:"listen_count"` // Nombre de fois que ce segment a été écouté SkipCount int64 `json:"skip_count"` // Nombre de fois que ce segment a été sauté Intensity float64 `json:"intensity"` // Intensité d'écoute (0-1, normalisée) AveragePlayTime float64 `json:"average_play_time"` // Temps de lecture moyen dans ce segment (secondes) } // HeatmapData représente les données complètes de la heatmap type HeatmapData struct { TrackID uuid.UUID `json:"track_id"` TrackDuration int `json:"track_duration"` // secondes SegmentSize int `json:"segment_size"` // Taille des segments (secondes) TotalSessions int64 `json:"total_sessions"` Segments []HeatmapSegment `json:"segments"` MaxIntensity float64 `json:"max_intensity"` // Intensité maximale (pour normalisation) GeneratedAt time.Time `json:"generated_at"` } // GenerateHeatmap génère les données de heatmap pour un track // T0376: Create Playback Analytics Heatmap Generation func (s *PlaybackHeatmapService) GenerateHeatmap(ctx context.Context, trackID uuid.UUID, segmentSize int) (*HeatmapData, error) { if trackID == uuid.Nil { return nil, fmt.Errorf("invalid track ID: %s", trackID) } if segmentSize <= 0 { segmentSize = 5 // Par défaut, segments de 5 secondes } if segmentSize > 60 { segmentSize = 60 // Maximum 60 secondes par segment } // 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) } // Calculer les zones écoutées et skip segments := s.calculateListenedZones(analytics, track.Duration, segmentSize) skipZones := s.calculateSkipZones(analytics, track.Duration, segmentSize) // Combiner les données et calculer l'intensité heatmapSegments := s.generateHeatmapSegments(segments, skipZones, track.Duration, segmentSize) // Trouver l'intensité maximale pour normalisation maxIntensity := 0.0 for _, seg := range heatmapSegments { if seg.Intensity > maxIntensity { maxIntensity = seg.Intensity } } // Normaliser les intensités (0-1) if maxIntensity > 0 { for i := range heatmapSegments { heatmapSegments[i].Intensity = heatmapSegments[i].Intensity / maxIntensity } } result := &HeatmapData{ TrackID: trackID, TrackDuration: track.Duration, SegmentSize: segmentSize, TotalSessions: int64(len(analytics)), Segments: heatmapSegments, MaxIntensity: maxIntensity, GeneratedAt: time.Now(), } s.logger.Info("Generated playback heatmap", zap.String("track_id", trackID.String()), zap.Int("total_sessions", len(analytics)), zap.Int("segment_size", segmentSize), zap.Int("segments_count", len(heatmapSegments))) return result, nil } // ListenedZone représente une zone écoutée type ListenedZone struct { StartTime float64 EndTime float64 ListenCount int64 TotalPlayTime float64 SessionCount int64 } // calculateListenedZones calcule les zones écoutées func (s *PlaybackHeatmapService) calculateListenedZones(analytics []models.PlaybackAnalytics, trackDuration int, segmentSize int) map[int]*ListenedZone { zones := make(map[int]*ListenedZone) totalSegments := (trackDuration + segmentSize - 1) / segmentSize // Arrondi supérieur // Initialiser tous les segments for i := 0; i < totalSegments; i++ { startTime := float64(i * segmentSize) endTime := float64((i + 1) * segmentSize) if endTime > float64(trackDuration) { endTime = float64(trackDuration) } zones[i] = &ListenedZone{ StartTime: startTime, EndTime: endTime, ListenCount: 0, TotalPlayTime: 0.0, SessionCount: 0, } } // Pour chaque session, calculer les segments écoutés for _, a := range analytics { playTimeSeconds := float64(a.PlayTime) if playTimeSeconds <= 0 { continue } // Pour chaque segment, vérifier s'il a été écouté for i := 0; i < totalSegments; i++ { segmentStart := float64(i * segmentSize) segmentEnd := float64((i + 1) * segmentSize) if segmentEnd > float64(trackDuration) { segmentEnd = float64(trackDuration) } // Si la session a atteint ce segment if playTimeSeconds >= segmentStart { zones[i].ListenCount++ // Calculer le temps passé dans ce segment segmentPlayTime := playTimeSeconds - segmentStart if segmentPlayTime > (segmentEnd - segmentStart) { segmentPlayTime = segmentEnd - segmentStart } zones[i].TotalPlayTime += segmentPlayTime zones[i].SessionCount++ } } } return zones } // SkipZone représente une zone skip type SkipZone struct { StartTime float64 EndTime float64 SkipCount int64 } // calculateSkipZones calcule les zones skip (basées sur les seeks) func (s *PlaybackHeatmapService) calculateSkipZones(analytics []models.PlaybackAnalytics, trackDuration int, segmentSize int) map[int]*SkipZone { zones := make(map[int]*SkipZone) totalSegments := (trackDuration + segmentSize - 1) / segmentSize // Initialiser tous les segments for i := 0; i < totalSegments; i++ { startTime := float64(i * segmentSize) endTime := float64((i + 1) * segmentSize) if endTime > float64(trackDuration) { endTime = float64(trackDuration) } zones[i] = &SkipZone{ StartTime: startTime, EndTime: endTime, SkipCount: 0, } } // Pour chaque session avec des seeks, considérer que les segments non écoutés sont skip for _, a := range analytics { playTimeSeconds := float64(a.PlayTime) seekCount := a.SeekCount // Si la session a des seeks, cela indique des sauts // On considère que les segments entre le début et le temps de lecture final sont potentiellement skip // si le seek count est élevé par rapport au temps de lecture if seekCount > 0 { // Calculer un ratio de skip basé sur les seeks // Plus il y a de seeks, plus il y a de zones skip potentielles skipRatio := float64(seekCount) / (playTimeSeconds + 1.0) // +1 pour éviter division par zéro // Pour chaque segment avant le temps de lecture final for i := 0; i < totalSegments; i++ { segmentStart := float64(i * segmentSize) segmentEnd := float64((i + 1) * segmentSize) if segmentEnd > float64(trackDuration) { segmentEnd = float64(trackDuration) } // Si le segment est avant le temps de lecture final et qu'il y a des seeks if segmentEnd <= playTimeSeconds { // Probabilité de skip basée sur le ratio if skipRatio > 0.1 { // Seuil pour considérer comme skip zones[i].SkipCount++ } } else if segmentStart < playTimeSeconds && segmentEnd > playTimeSeconds { // Segment partiellement écouté avec seeks = probablement skip if seekCount > 1 { zones[i].SkipCount++ } } } } } return zones } // generateHeatmapSegments génère les segments de heatmap en combinant les zones écoutées et skip func (s *PlaybackHeatmapService) generateHeatmapSegments(listenedZones map[int]*ListenedZone, skipZones map[int]*SkipZone, trackDuration int, segmentSize int) []HeatmapSegment { totalSegments := (trackDuration + segmentSize - 1) / segmentSize segments := make([]HeatmapSegment, 0, totalSegments) for i := 0; i < totalSegments; i++ { listenedZone := listenedZones[i] skipZone := skipZones[i] if listenedZone == nil { continue } startTime := float64(i * segmentSize) endTime := float64((i + 1) * segmentSize) if endTime > float64(trackDuration) { endTime = float64(trackDuration) } // Calculer l'intensité d'écoute // Basée sur : nombre d'écoutes, temps moyen passé, et inverse des skips intensity := 0.0 if listenedZone.SessionCount > 0 { // Intensité basée sur le nombre d'écoutes et le temps moyen avgPlayTime := listenedZone.TotalPlayTime / float64(listenedZone.SessionCount) segmentDuration := endTime - startTime completionRatio := avgPlayTime / segmentDuration if completionRatio > 1.0 { completionRatio = 1.0 } // Intensité = (nombre d'écoutes * ratio de complétion) - (skips * pénalité) intensity = float64(listenedZone.ListenCount) * completionRatio if skipZone != nil && skipZone.SkipCount > 0 { // Pénalité pour les skips (réduit l'intensité) skipPenalty := float64(skipZone.SkipCount) * 0.5 intensity = intensity - skipPenalty if intensity < 0 { intensity = 0 } } } // Calculer le temps de lecture moyen averagePlayTime := 0.0 if listenedZone.SessionCount > 0 { averagePlayTime = listenedZone.TotalPlayTime / float64(listenedZone.SessionCount) } skipCount := int64(0) if skipZone != nil { skipCount = skipZone.SkipCount } segments = append(segments, HeatmapSegment{ StartTime: startTime, EndTime: endTime, ListenCount: listenedZone.ListenCount, SkipCount: skipCount, Intensity: intensity, AveragePlayTime: averagePlayTime, }) } return segments } // GetHeatmapIntensityArray retourne un tableau simple d'intensités pour visualisation // Utile pour les graphiques de heatmap simples func (s *PlaybackHeatmapService) GetHeatmapIntensityArray(ctx context.Context, trackID uuid.UUID, segmentSize int) ([]float64, error) { heatmap, err := s.GenerateHeatmap(ctx, trackID, segmentSize) if err != nil { return nil, err } intensities := make([]float64, len(heatmap.Segments)) for i, seg := range heatmap.Segments { intensities[i] = seg.Intensity } return intensities, nil }