617 lines
21 KiB
Go
617 lines
21 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"github.com/google/uuid"
|
|
"time"
|
|
|
|
"veza-backend-api/internal/models"
|
|
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// PlaybackAnalyticsService gère les analytics de lecture de tracks
|
|
// T0357: Create Playback Analytics Service
|
|
// T0381: Create Playback Analytics Performance Optimization
|
|
type PlaybackAnalyticsService struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
cache *CacheService // Optionnel, pour le cache des agrégations
|
|
cacheTTL time.Duration // TTL pour le cache des statistiques
|
|
batchSize int // Taille du batch pour l'enregistrement en lot
|
|
}
|
|
|
|
// NewPlaybackAnalyticsService crée un nouveau service d'analytics de lecture
|
|
func NewPlaybackAnalyticsService(db *gorm.DB, logger *zap.Logger) *PlaybackAnalyticsService {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &PlaybackAnalyticsService{
|
|
db: db,
|
|
logger: logger,
|
|
cache: nil, // Cache optionnel
|
|
cacheTTL: 5 * time.Minute, // TTL par défaut de 5 minutes
|
|
batchSize: 100, // Taille de batch par défaut
|
|
}
|
|
}
|
|
|
|
// NewPlaybackAnalyticsServiceWithCache crée un nouveau service avec cache
|
|
// T0381: Create Playback Analytics Performance Optimization
|
|
func NewPlaybackAnalyticsServiceWithCache(db *gorm.DB, cache *CacheService, logger *zap.Logger) *PlaybackAnalyticsService {
|
|
service := NewPlaybackAnalyticsService(db, logger)
|
|
service.cache = cache
|
|
return service
|
|
}
|
|
|
|
// SetBatchSize définit la taille du batch pour l'enregistrement en lot
|
|
// T0381: Create Playback Analytics Performance Optimization
|
|
func (s *PlaybackAnalyticsService) SetBatchSize(size int) {
|
|
if size > 0 {
|
|
s.batchSize = size
|
|
}
|
|
}
|
|
|
|
// RecordPlayback enregistre un événement d'analytics de lecture
|
|
// T0357: Create Playback Analytics Service
|
|
func (s *PlaybackAnalyticsService) RecordPlayback(ctx context.Context, analytics *models.PlaybackAnalytics) error {
|
|
// Valider les paramètres
|
|
if analytics.TrackID == uuid.Nil {
|
|
return fmt.Errorf("invalid track ID: 0")
|
|
}
|
|
if analytics.UserID == uuid.Nil {
|
|
return fmt.Errorf("invalid user ID: nil UUID")
|
|
}
|
|
if analytics.PlayTime < 0 {
|
|
return fmt.Errorf("invalid play time: %d", analytics.PlayTime)
|
|
}
|
|
if analytics.PauseCount < 0 {
|
|
return fmt.Errorf("invalid pause count: %d", analytics.PauseCount)
|
|
}
|
|
if analytics.SeekCount < 0 {
|
|
return fmt.Errorf("invalid seek count: %d", analytics.SeekCount)
|
|
}
|
|
if analytics.CompletionRate < 0 || analytics.CompletionRate > 100 {
|
|
return fmt.Errorf("invalid completion rate: %f (must be between 0 and 100)", analytics.CompletionRate)
|
|
}
|
|
if analytics.StartedAt.IsZero() {
|
|
return fmt.Errorf("started_at is required")
|
|
}
|
|
|
|
// Vérifier que le track existe
|
|
var track models.Track
|
|
if err := s.db.WithContext(ctx).First(&track, analytics.TrackID).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return fmt.Errorf("track not found: %s", analytics.TrackID)
|
|
}
|
|
return fmt.Errorf("failed to get track: %w", err)
|
|
}
|
|
|
|
// Calculer le taux de complétion si non fourni
|
|
if analytics.CompletionRate == 0 && track.Duration > 0 {
|
|
analytics.CompletionRate = s.CalculateCompletionRate(analytics.PlayTime, track.Duration)
|
|
}
|
|
|
|
// Enregistrer l'analytics avec retry logic
|
|
// T0385: Create Playback Analytics Error Handling
|
|
maxRetries := 3
|
|
var lastErr error
|
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
|
err := s.db.WithContext(ctx).Create(analytics).Error
|
|
if err == nil {
|
|
// Succès
|
|
if attempt > 0 {
|
|
s.logger.Info("Playback analytics recorded after retry",
|
|
zap.Int("attempt", attempt+1),
|
|
zap.String("track_id", analytics.TrackID.String()),
|
|
zap.String("user_id", analytics.UserID.String()))
|
|
}
|
|
break
|
|
}
|
|
|
|
lastErr = err
|
|
|
|
// Logger l'erreur
|
|
s.logger.Warn("Failed to record playback analytics, retrying",
|
|
zap.Error(err),
|
|
zap.Int("attempt", attempt+1),
|
|
zap.Int("max_retries", maxRetries),
|
|
zap.String("track_id", analytics.TrackID.String()),
|
|
zap.String("user_id", analytics.UserID.String()))
|
|
|
|
// Ne pas retry pour certaines erreurs (contraintes, etc.)
|
|
if attempt < maxRetries-1 {
|
|
// Attendre avant de retry (exponential backoff)
|
|
backoffDuration := time.Duration(attempt+1) * 100 * time.Millisecond
|
|
time.Sleep(backoffDuration)
|
|
}
|
|
}
|
|
|
|
if lastErr != nil {
|
|
s.logger.Error("Failed to record playback analytics after all retries",
|
|
zap.Error(lastErr),
|
|
zap.Int("max_retries", maxRetries),
|
|
zap.String("track_id", analytics.TrackID.String()),
|
|
zap.String("user_id", analytics.UserID.String()))
|
|
return fmt.Errorf("failed to record playback analytics after %d retries: %w", maxRetries, lastErr)
|
|
}
|
|
|
|
// Invalider le cache si disponible
|
|
if s.cache != nil {
|
|
cacheKey := fmt.Sprintf("playback_stats:track:%s", analytics.TrackID)
|
|
if err := s.cache.Delete(ctx, cacheKey); err != nil {
|
|
s.logger.Warn("Failed to invalidate cache", zap.Error(err), zap.String("track_id", analytics.TrackID.String()))
|
|
}
|
|
}
|
|
|
|
s.logger.Info("Playback analytics recorded",
|
|
zap.String("id", analytics.ID.String()),
|
|
zap.String("track_id", analytics.TrackID.String()),
|
|
zap.String("user_id", analytics.UserID.String()),
|
|
zap.Int("play_time", analytics.PlayTime),
|
|
zap.Float64("completion_rate", analytics.CompletionRate))
|
|
|
|
return nil
|
|
}
|
|
|
|
// RecordPlaybackBatch enregistre plusieurs analytics en lot pour optimiser les performances
|
|
// T0381: Create Playback Analytics Performance Optimization
|
|
func (s *PlaybackAnalyticsService) RecordPlaybackBatch(ctx context.Context, analyticsList []*models.PlaybackAnalytics) error {
|
|
if len(analyticsList) == 0 {
|
|
return fmt.Errorf("analytics list cannot be empty")
|
|
}
|
|
|
|
// Valider tous les analytics avant l'insertion
|
|
for i, analytics := range analyticsList {
|
|
if analytics.TrackID == uuid.Nil {
|
|
return fmt.Errorf("invalid track ID at index %d: 0", i)
|
|
}
|
|
if analytics.UserID == uuid.Nil {
|
|
return fmt.Errorf("invalid user ID at index %d: nil UUID", i)
|
|
}
|
|
if analytics.PlayTime < 0 {
|
|
return fmt.Errorf("invalid play time at index %d: %d", i, analytics.PlayTime)
|
|
}
|
|
if analytics.StartedAt.IsZero() {
|
|
return fmt.Errorf("started_at is required at index %d", i)
|
|
}
|
|
}
|
|
|
|
// Enregistrer par batch pour optimiser les performances
|
|
trackIDs := make(map[uuid.UUID]bool)
|
|
for i := 0; i < len(analyticsList); i += s.batchSize {
|
|
end := i + s.batchSize
|
|
if end > len(analyticsList) {
|
|
end = len(analyticsList)
|
|
}
|
|
|
|
batch := analyticsList[i:end]
|
|
if err := s.db.WithContext(ctx).Create(batch).Error; err != nil {
|
|
s.logger.Error("Failed to record playback analytics batch",
|
|
zap.Error(err),
|
|
zap.Int("batch_start", i),
|
|
zap.Int("batch_end", end))
|
|
return fmt.Errorf("failed to record playback analytics batch: %w", err)
|
|
}
|
|
|
|
// Collecter les track IDs pour invalider le cache
|
|
for _, analytics := range batch {
|
|
trackIDs[analytics.TrackID] = true
|
|
}
|
|
}
|
|
|
|
// Invalider le cache pour tous les tracks affectés
|
|
if s.cache != nil {
|
|
for trackID := range trackIDs {
|
|
cacheKey := fmt.Sprintf("playback_stats:track:%s", trackID)
|
|
if err := s.cache.Delete(ctx, cacheKey); err != nil {
|
|
s.logger.Warn("Failed to invalidate cache", zap.Error(err), zap.String("track_id", trackID.String()))
|
|
}
|
|
}
|
|
}
|
|
|
|
s.logger.Info("Playback analytics batch recorded",
|
|
zap.Int("count", len(analyticsList)),
|
|
zap.Int("batches", (len(analyticsList)+s.batchSize-1)/s.batchSize))
|
|
|
|
return nil
|
|
}
|
|
|
|
// CalculateCompletionRate calcule le taux de complétion en pourcentage
|
|
// playTime: temps de lecture en secondes
|
|
// trackDuration: durée totale du track en secondes
|
|
// Retourne le taux de complétion (0-100)
|
|
func (s *PlaybackAnalyticsService) CalculateCompletionRate(playTime int, trackDuration int) float64 {
|
|
if trackDuration <= 0 {
|
|
return 0.0
|
|
}
|
|
if playTime < 0 {
|
|
return 0.0
|
|
}
|
|
|
|
rate := float64(playTime) / float64(trackDuration) * 100.0
|
|
|
|
// Limiter à 100%
|
|
if rate > 100.0 {
|
|
rate = 100.0
|
|
}
|
|
|
|
return rate
|
|
}
|
|
|
|
// PlaybackStats représente les statistiques agrégées de lecture
|
|
type PlaybackStats struct {
|
|
TotalSessions int64 `json:"total_sessions"`
|
|
TotalPlayTime int64 `json:"total_play_time"` // seconds
|
|
AveragePlayTime float64 `json:"average_play_time"` // seconds
|
|
TotalPauses int64 `json:"total_pauses"`
|
|
AveragePauses float64 `json:"average_pauses"`
|
|
TotalSeeks int64 `json:"total_seeks"`
|
|
AverageSeeks float64 `json:"average_seeks"`
|
|
AverageCompletion float64 `json:"average_completion"` // percentage
|
|
CompletionRate float64 `json:"completion_rate"` // percentage of sessions with >90% completion
|
|
}
|
|
|
|
// GetTrackStats récupère les statistiques agrégées pour un track
|
|
// T0381: Optimisé avec cache
|
|
func (s *PlaybackAnalyticsService) GetTrackStats(ctx context.Context, trackID uuid.UUID) (*PlaybackStats, error) {
|
|
if trackID == uuid.Nil {
|
|
return nil, fmt.Errorf("invalid track ID: 0")
|
|
}
|
|
|
|
// Vérifier le cache si disponible
|
|
if s.cache != nil {
|
|
cacheKey := fmt.Sprintf("playback_stats:track:%s", trackID)
|
|
var cachedStats PlaybackStats
|
|
if err := s.cache.Get(ctx, cacheKey, &cachedStats); err == nil {
|
|
s.logger.Debug("Cache hit for track stats", zap.String("track_id", trackID.String()))
|
|
return &cachedStats, nil
|
|
}
|
|
}
|
|
|
|
// Vérifier que le track existe
|
|
var track models.Track
|
|
if err := s.db.WithContext(ctx).First(&track, 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)
|
|
}
|
|
|
|
var stats PlaybackStats
|
|
|
|
// Total sessions
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("track_id = ?", trackID).
|
|
Count(&stats.TotalSessions).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to count sessions: %w", err)
|
|
}
|
|
|
|
if stats.TotalSessions == 0 {
|
|
return &stats, nil
|
|
}
|
|
|
|
// Total play time
|
|
var totalPlayTime int64
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("track_id = ?", trackID).
|
|
Select("COALESCE(SUM(play_time), 0)").
|
|
Scan(&totalPlayTime).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to calculate total play time: %w", err)
|
|
}
|
|
stats.TotalPlayTime = totalPlayTime
|
|
|
|
// Average play time
|
|
stats.AveragePlayTime = float64(totalPlayTime) / float64(stats.TotalSessions)
|
|
|
|
// Total pauses
|
|
var totalPauses int64
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("track_id = ?", trackID).
|
|
Select("COALESCE(SUM(pause_count), 0)").
|
|
Scan(&totalPauses).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to calculate total pauses: %w", err)
|
|
}
|
|
stats.TotalPauses = totalPauses
|
|
stats.AveragePauses = float64(totalPauses) / float64(stats.TotalSessions)
|
|
|
|
// Total seeks
|
|
var totalSeeks int64
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("track_id = ?", trackID).
|
|
Select("COALESCE(SUM(seek_count), 0)").
|
|
Scan(&totalSeeks).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to calculate total seeks: %w", err)
|
|
}
|
|
stats.TotalSeeks = totalSeeks
|
|
stats.AverageSeeks = float64(totalSeeks) / float64(stats.TotalSessions)
|
|
|
|
// Average completion rate
|
|
var avgCompletion float64
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("track_id = ?", trackID).
|
|
Select("COALESCE(AVG(completion_rate), 0)").
|
|
Scan(&avgCompletion).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to calculate average completion: %w", err)
|
|
}
|
|
stats.AverageCompletion = avgCompletion
|
|
|
|
// Completion rate (sessions with >90% completion)
|
|
var completedSessions int64
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("track_id = ? AND completion_rate >= 90", trackID).
|
|
Count(&completedSessions).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to count completed sessions: %w", err)
|
|
}
|
|
if stats.TotalSessions > 0 {
|
|
stats.CompletionRate = float64(completedSessions) / float64(stats.TotalSessions) * 100.0
|
|
}
|
|
|
|
// Mettre en cache si disponible
|
|
if s.cache != nil {
|
|
cacheKey := fmt.Sprintf("playback_stats:track:%s", trackID)
|
|
if err := s.cache.Set(ctx, cacheKey, stats, s.cacheTTL); err != nil {
|
|
s.logger.Warn("Failed to cache track stats", zap.Error(err), zap.String("track_id", trackID.String()))
|
|
}
|
|
}
|
|
|
|
return &stats, nil
|
|
}
|
|
|
|
// GetUserStats récupère les statistiques agrégées pour un utilisateur
|
|
func (s *PlaybackAnalyticsService) GetUserStats(ctx context.Context, userID uuid.UUID) (*PlaybackStats, error) {
|
|
if userID == uuid.Nil {
|
|
return nil, fmt.Errorf("invalid user ID: nil UUID")
|
|
}
|
|
|
|
// Vérifier que l'utilisateur existe
|
|
var user models.User
|
|
if err := s.db.WithContext(ctx).First(&user, userID).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, fmt.Errorf("user not found: %s", userID)
|
|
}
|
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
|
}
|
|
|
|
var stats PlaybackStats
|
|
|
|
// Total sessions
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("user_id = ?", userID).
|
|
Count(&stats.TotalSessions).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to count sessions: %w", err)
|
|
}
|
|
|
|
if stats.TotalSessions == 0 {
|
|
return &stats, nil
|
|
}
|
|
|
|
// Total play time
|
|
var totalPlayTime int64
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("user_id = ?", userID).
|
|
Select("COALESCE(SUM(play_time), 0)").
|
|
Scan(&totalPlayTime).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to calculate total play time: %w", err)
|
|
}
|
|
stats.TotalPlayTime = totalPlayTime
|
|
stats.AveragePlayTime = float64(totalPlayTime) / float64(stats.TotalSessions)
|
|
|
|
// Total pauses
|
|
var totalPauses int64
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("user_id = ?", userID).
|
|
Select("COALESCE(SUM(pause_count), 0)").
|
|
Scan(&totalPauses).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to calculate total pauses: %w", err)
|
|
}
|
|
stats.TotalPauses = totalPauses
|
|
stats.AveragePauses = float64(totalPauses) / float64(stats.TotalSessions)
|
|
|
|
// Total seeks
|
|
var totalSeeks int64
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("user_id = ?", userID).
|
|
Select("COALESCE(SUM(seek_count), 0)").
|
|
Scan(&totalSeeks).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to calculate total seeks: %w", err)
|
|
}
|
|
stats.TotalSeeks = totalSeeks
|
|
stats.AverageSeeks = float64(totalSeeks) / float64(stats.TotalSessions)
|
|
|
|
// Average completion rate
|
|
var avgCompletion float64
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("user_id = ?", userID).
|
|
Select("COALESCE(AVG(completion_rate), 0)").
|
|
Scan(&avgCompletion).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to calculate average completion: %w", err)
|
|
}
|
|
stats.AverageCompletion = avgCompletion
|
|
|
|
// Completion rate (sessions with >90% completion)
|
|
var completedSessions int64
|
|
if err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("user_id = ? AND completion_rate >= 90", userID).
|
|
Count(&completedSessions).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to count completed sessions: %w", err)
|
|
}
|
|
if stats.TotalSessions > 0 {
|
|
stats.CompletionRate = float64(completedSessions) / float64(stats.TotalSessions) * 100.0
|
|
}
|
|
|
|
return &stats, nil
|
|
}
|
|
|
|
// GetSessionsByDateRange récupère les sessions dans une plage de dates
|
|
func (s *PlaybackAnalyticsService) GetSessionsByDateRange(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time) ([]models.PlaybackAnalytics, error) {
|
|
return s.GetSessionsByDateRangePaginated(ctx, trackID, startDate, endDate, 0, 0)
|
|
}
|
|
|
|
// PaginationParams représente les paramètres de pagination
|
|
// T0381: Create Playback Analytics Performance Optimization
|
|
type PaginationParams struct {
|
|
Page int // Numéro de page (commence à 1)
|
|
PageSize int // Taille de la page
|
|
}
|
|
|
|
// PaginatedResult représente un résultat paginé
|
|
// T0381: Create Playback Analytics Performance Optimization
|
|
type PaginatedResult[T any] struct {
|
|
Data []T `json:"data"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"page_size"`
|
|
TotalPages int `json:"total_pages"`
|
|
}
|
|
|
|
// GetSessionsByDateRangePaginated récupère les sessions dans une plage de dates avec pagination
|
|
// T0381: Create Playback Analytics Performance Optimization
|
|
func (s *PlaybackAnalyticsService) GetSessionsByDateRangePaginated(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, page, pageSize int) ([]models.PlaybackAnalytics, error) {
|
|
if trackID == uuid.Nil {
|
|
return nil, fmt.Errorf("invalid track ID: 0")
|
|
}
|
|
|
|
query := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("track_id = ? AND created_at >= ? AND created_at <= ?", trackID, startDate, endDate).
|
|
Order("created_at DESC")
|
|
|
|
// Appliquer la pagination si spécifiée
|
|
if pageSize > 0 {
|
|
offset := (page - 1) * pageSize
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
query = query.Offset(offset).Limit(pageSize)
|
|
}
|
|
|
|
var sessions []models.PlaybackAnalytics
|
|
err := query.Find(&sessions).Error
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get sessions: %w", err)
|
|
}
|
|
|
|
return sessions, nil
|
|
}
|
|
|
|
// GetSessionsByDateRangePaginatedResult récupère les sessions avec pagination complète
|
|
// T0381: Create Playback Analytics Performance Optimization
|
|
func (s *PlaybackAnalyticsService) GetSessionsByDateRangePaginatedResult(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, page, pageSize int) (*PaginatedResult[models.PlaybackAnalytics], error) {
|
|
if trackID == uuid.Nil {
|
|
return nil, fmt.Errorf("invalid track ID: 0")
|
|
}
|
|
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
if pageSize < 1 {
|
|
pageSize = 50 // Taille par défaut
|
|
}
|
|
if pageSize > 1000 {
|
|
pageSize = 1000 // Limite maximale
|
|
}
|
|
|
|
// Compter le total
|
|
var total int64
|
|
err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
|
|
Where("track_id = ? AND created_at >= ? AND created_at <= ?", trackID, startDate, endDate).
|
|
Count(&total).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to count sessions: %w", err)
|
|
}
|
|
|
|
// Récupérer les données paginées
|
|
sessions, err := s.GetSessionsByDateRangePaginated(ctx, trackID, startDate, endDate, page, pageSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
|
|
|
|
return &PaginatedResult[models.PlaybackAnalytics]{
|
|
Data: sessions,
|
|
Total: total,
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
TotalPages: totalPages,
|
|
}, nil
|
|
}
|
|
|
|
// TrackCompletion détecte et enregistre la completion d'un track (≥95%)
|
|
// T0366: Create Playback Completion Tracking
|
|
func (s *PlaybackAnalyticsService) TrackCompletion(ctx context.Context, analytics *models.PlaybackAnalytics, trackDuration int) error {
|
|
if analytics == nil {
|
|
return fmt.Errorf("analytics cannot be nil")
|
|
}
|
|
|
|
if analytics.ID == uuid.Nil {
|
|
return fmt.Errorf("analytics must be saved before tracking completion")
|
|
}
|
|
|
|
if trackDuration <= 0 {
|
|
return fmt.Errorf("invalid track duration: %d", trackDuration)
|
|
}
|
|
|
|
// Calculer le taux de complétion
|
|
completionRate := s.CalculateCompletionRate(analytics.PlayTime, trackDuration)
|
|
analytics.CompletionRate = completionRate
|
|
|
|
// Détecter si le track est complété (≥95%)
|
|
if completionRate >= 95.0 {
|
|
// Marquer comme complété en définissant EndedAt
|
|
now := time.Now()
|
|
analytics.EndedAt = &now
|
|
|
|
s.logger.Info("Track completion detected",
|
|
zap.String("analytics_id", analytics.ID.String()),
|
|
zap.String("track_id", analytics.TrackID.String()),
|
|
zap.String("user_id", analytics.UserID.String()),
|
|
zap.Float64("completion_rate", completionRate),
|
|
zap.Int("play_time", analytics.PlayTime),
|
|
zap.Int("track_duration", trackDuration))
|
|
}
|
|
|
|
// Mettre à jour les analytics dans la base de données
|
|
if err := s.db.WithContext(ctx).Save(analytics).Error; err != nil {
|
|
s.logger.Error("Failed to update analytics completion",
|
|
zap.Error(err),
|
|
zap.String("analytics_id", analytics.ID.String()),
|
|
zap.String("track_id", analytics.TrackID.String()))
|
|
return fmt.Errorf("failed to update analytics completion: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdatePlaybackProgress met à jour le progrès de lecture et détecte la completion
|
|
// T0366: Create Playback Completion Tracking
|
|
func (s *PlaybackAnalyticsService) UpdatePlaybackProgress(ctx context.Context, analyticsID uuid.UUID, playTime int, trackDuration int) error {
|
|
if analyticsID == uuid.Nil {
|
|
return fmt.Errorf("invalid analytics ID: 0")
|
|
}
|
|
|
|
if playTime < 0 {
|
|
return fmt.Errorf("invalid play time: %d", playTime)
|
|
}
|
|
|
|
if trackDuration <= 0 {
|
|
return fmt.Errorf("invalid track duration: %d", trackDuration)
|
|
}
|
|
|
|
// Récupérer l'analytics existant
|
|
var analytics models.PlaybackAnalytics
|
|
if err := s.db.WithContext(ctx).First(&analytics, analyticsID).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return fmt.Errorf("analytics not found: %s", analyticsID)
|
|
}
|
|
return fmt.Errorf("failed to get analytics: %w", err)
|
|
}
|
|
|
|
// Mettre à jour le temps de lecture
|
|
analytics.PlayTime = playTime
|
|
|
|
// Utiliser TrackCompletion pour calculer et détecter la completion
|
|
return s.TrackCompletion(ctx, &analytics, trackDuration)
|
|
}
|