veza/veza-backend-api/internal/services/playback_heatmap_service.go
2025-12-03 20:29:37 +01:00

340 lines
11 KiB
Go

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
}