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) // 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) []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 }