veza/veza-backend-api/internal/services/playback_aggregation_service.go

350 lines
11 KiB
Go

package services
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"veza-backend-api/internal/models"
"go.uber.org/zap"
"gorm.io/gorm"
)
// PlaybackAggregationService gère l'agrégation des analytics de lecture
// T0365: Create Playback Analytics Aggregation Service
type PlaybackAggregationService struct {
db *gorm.DB
logger *zap.Logger
}
// NewPlaybackAggregationService crée un nouveau service d'agrégation d'analytics
func NewPlaybackAggregationService(db *gorm.DB, logger *zap.Logger) *PlaybackAggregationService {
if logger == nil {
logger = zap.NewNop()
}
return &PlaybackAggregationService{
db: db,
logger: logger,
}
}
// PeriodType représente le type de période d'agrégation
type PeriodType string
const (
PeriodDay PeriodType = "day"
PeriodWeek PeriodType = "week"
PeriodMonth PeriodType = "month"
)
// PeriodAggregation représente les données agrégées pour une période
type PeriodAggregation struct {
Period string `json:"period"` // Format: YYYY-MM-DD, YYYY-WW, YYYY-MM
Sessions int64 `json:"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
}
// AggregationResult représente le résultat d'une agrégation
type AggregationResult struct {
Periods []PeriodAggregation `json:"periods"`
TotalSessions int64 `json:"total_sessions"`
TotalPlayTime int64 `json:"total_play_time"` // seconds
AveragePlayTime float64 `json:"average_play_time"` // seconds
Trends *TrendsData `json:"trends,omitempty"`
}
// TrendsData représente les tendances calculées
type TrendsData struct {
SessionsTrend float64 `json:"sessions_trend"` // % de changement
PlayTimeTrend float64 `json:"play_time_trend"` // % de changement
CompletionTrend float64 `json:"completion_trend"` // % de changement
PausesTrend float64 `json:"pauses_trend"` // % de changement
SeeksTrend float64 `json:"seeks_trend"` // % de changement
}
// AggregateByPeriod agrège les analytics par période (day, week, month)
// T0365: Create Playback Analytics Aggregation Service
func (s *PlaybackAggregationService) AggregateByPeriod(ctx context.Context, trackID uuid.UUID, period PeriodType, startDate, endDate time.Time) (*AggregationResult, error) {
if trackID == uuid.Nil {
return nil, fmt.Errorf("invalid track ID: %s", trackID)
}
// Valider le type de période
if period != PeriodDay && period != PeriodWeek && period != PeriodMonth {
return nil, fmt.Errorf("invalid period type: %s (must be day, week, or month)", period)
}
// 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: %d", trackID)
}
return nil, fmt.Errorf("failed to get track: %w", err)
}
// Récupérer toutes les sessions dans la plage de dates
var sessions []models.PlaybackAnalytics
err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
Where("track_id = ? AND created_at >= ? AND created_at <= ?", trackID, startDate, endDate).
Order("created_at ASC").
Find(&sessions).Error
if err != nil {
return nil, fmt.Errorf("failed to get sessions: %w", err)
}
// Grouper par période
periodMap := make(map[string]*PeriodAggregation)
for _, session := range sessions {
periodKey := s.getPeriodKey(session.CreatedAt, period)
if periodMap[periodKey] == nil {
periodMap[periodKey] = &PeriodAggregation{
Period: periodKey,
}
}
agg := periodMap[periodKey]
agg.Sessions++
agg.TotalPlayTime += int64(session.PlayTime)
agg.TotalPauses += int64(session.PauseCount)
agg.TotalSeeks += int64(session.SeekCount)
agg.AverageCompletion += session.CompletionRate
// Compter les sessions complétées
if session.CompletionRate >= 90 {
agg.CompletionRate += 1.0
}
}
// Calculer les moyennes pour chaque période
var periods []PeriodAggregation
var totalSessions int64
var totalPlayTime int64
var totalPauses int64
var totalSeeks int64
var totalCompletion float64
for _, agg := range periodMap {
if agg.Sessions > 0 {
agg.AveragePlayTime = float64(agg.TotalPlayTime) / float64(agg.Sessions)
agg.AveragePauses = float64(agg.TotalPauses) / float64(agg.Sessions)
agg.AverageSeeks = float64(agg.TotalSeeks) / float64(agg.Sessions)
agg.AverageCompletion = agg.AverageCompletion / float64(agg.Sessions)
agg.CompletionRate = (agg.CompletionRate / float64(agg.Sessions)) * 100.0
}
periods = append(periods, *agg)
totalSessions += agg.Sessions
totalPlayTime += agg.TotalPlayTime
totalPauses += agg.TotalPauses
totalSeeks += agg.TotalSeeks
totalCompletion += agg.AverageCompletion * float64(agg.Sessions)
}
// Trier les périodes par ordre chronologique
periods = s.sortPeriods(periods, period)
// Calculer les moyennes globales
var averagePlayTime float64
if totalSessions > 0 {
averagePlayTime = float64(totalPlayTime) / float64(totalSessions)
}
// Calculer les tendances (comparaison entre la première et la dernière période)
var trends *TrendsData
if len(periods) >= 2 {
trends = s.calculateTrends(periods)
}
result := &AggregationResult{
Periods: periods,
TotalSessions: totalSessions,
TotalPlayTime: totalPlayTime,
AveragePlayTime: averagePlayTime,
Trends: trends,
}
return result, nil
}
// getPeriodKey génère une clé de période basée sur la date et le type de période
func (s *PlaybackAggregationService) getPeriodKey(date time.Time, period PeriodType) string {
switch period {
case PeriodDay:
return date.Format("2006-01-02")
case PeriodWeek:
year, week := date.ISOWeek()
return fmt.Sprintf("%d-W%02d", year, week)
case PeriodMonth:
return date.Format("2006-01")
default:
return date.Format("2006-01-02")
}
}
// sortPeriods trie les périodes par ordre chronologique
func (s *PlaybackAggregationService) sortPeriods(periods []PeriodAggregation, period PeriodType) []PeriodAggregation {
// Utiliser un tri simple basé sur la clé de période (qui est déjà formatée)
for i := 0; i < len(periods)-1; i++ {
for j := i + 1; j < len(periods); j++ {
if periods[i].Period > periods[j].Period {
periods[i], periods[j] = periods[j], periods[i]
}
}
}
return periods
}
// calculateTrends calcule les tendances entre la première et la dernière période
func (s *PlaybackAggregationService) calculateTrends(periods []PeriodAggregation) *TrendsData {
if len(periods) < 2 {
return nil
}
first := periods[0]
last := periods[len(periods)-1]
trends := &TrendsData{}
// Tendance des sessions
if first.Sessions > 0 {
trends.SessionsTrend = float64(last.Sessions-first.Sessions) / float64(first.Sessions) * 100.0
} else if last.Sessions > 0 {
trends.SessionsTrend = 100.0
}
// Tendance du temps de lecture
if first.AveragePlayTime > 0 {
trends.PlayTimeTrend = (last.AveragePlayTime - first.AveragePlayTime) / first.AveragePlayTime * 100.0
} else if last.AveragePlayTime > 0 {
trends.PlayTimeTrend = 100.0
}
// Tendance du taux de complétion
if first.AverageCompletion > 0 {
trends.CompletionTrend = (last.AverageCompletion - first.AverageCompletion) / first.AverageCompletion * 100.0
} else if last.AverageCompletion > 0 {
trends.CompletionTrend = 100.0
}
// Tendance des pauses
if first.AveragePauses > 0 {
trends.PausesTrend = (last.AveragePauses - first.AveragePauses) / first.AveragePauses * 100.0
} else if last.AveragePauses > 0 {
trends.PausesTrend = 100.0
}
// Tendance des seeks
if first.AverageSeeks > 0 {
trends.SeeksTrend = (last.AverageSeeks - first.AverageSeeks) / first.AverageSeeks * 100.0
} else if last.AverageSeeks > 0 {
trends.SeeksTrend = 100.0
}
return trends
}
// AggregateByDateRange agrège les analytics dans une plage de dates sans groupement par période
func (s *PlaybackAggregationService) AggregateByDateRange(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time) (*PeriodAggregation, error) {
if trackID == uuid.Nil {
return nil, fmt.Errorf("invalid track ID: %s", trackID)
}
// 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: %d", trackID)
}
return nil, fmt.Errorf("failed to get track: %w", err)
}
// Récupérer toutes les sessions dans la plage de dates
var sessions []models.PlaybackAnalytics
err := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
Where("track_id = ? AND created_at >= ? AND created_at <= ?", trackID, startDate, endDate).
Find(&sessions).Error
if err != nil {
return nil, fmt.Errorf("failed to get sessions: %w", err)
}
agg := &PeriodAggregation{
Period: fmt.Sprintf("%s to %s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")),
}
for _, session := range sessions {
agg.Sessions++
agg.TotalPlayTime += int64(session.PlayTime)
agg.TotalPauses += int64(session.PauseCount)
agg.TotalSeeks += int64(session.SeekCount)
agg.AverageCompletion += session.CompletionRate
if session.CompletionRate >= 90 {
agg.CompletionRate += 1.0
}
}
if agg.Sessions > 0 {
agg.AveragePlayTime = float64(agg.TotalPlayTime) / float64(agg.Sessions)
agg.AveragePauses = float64(agg.TotalPauses) / float64(agg.Sessions)
agg.AverageSeeks = float64(agg.TotalSeeks) / float64(agg.Sessions)
agg.AverageCompletion = agg.AverageCompletion / float64(agg.Sessions)
agg.CompletionRate = (agg.CompletionRate / float64(agg.Sessions)) * 100.0
}
return agg, nil
}
// GetTopTracksByPlayback récupère les tracks les plus écoutés
func (s *PlaybackAggregationService) GetTopTracksByPlayback(ctx context.Context, limit int, startDate, endDate *time.Time) ([]map[string]interface{}, error) {
if limit <= 0 {
limit = 10
}
query := s.db.WithContext(ctx).Model(&models.PlaybackAnalytics{}).
Select("track_id, COUNT(*) as sessions, SUM(play_time) as total_play_time, AVG(completion_rate) as avg_completion").
Group("track_id").
Order("sessions DESC").
Limit(limit)
if startDate != nil && endDate != nil {
query = query.Where("created_at >= ? AND created_at <= ?", *startDate, *endDate)
}
var results []struct {
TrackID uuid.UUID `gorm:"column:track_id"`
Sessions int64 `gorm:"column:sessions"`
TotalPlayTime int64 `gorm:"column:total_play_time"`
AvgCompletion float64 `gorm:"column:avg_completion"`
}
if err := query.Scan(&results).Error; err != nil {
return nil, fmt.Errorf("failed to get top tracks: %w", err)
}
var topTracks []map[string]interface{}
for _, result := range results {
topTracks = append(topTracks, map[string]interface{}{
"track_id": result.TrackID,
"sessions": result.Sessions,
"total_play_time": result.TotalPlayTime,
"avg_completion": result.AvgCompletion,
})
}
return topTracks, nil
}