350 lines
11 KiB
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)
|
|
|
|
// 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
|
|
}
|