340 lines
11 KiB
Go
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
|
|
}
|