426 lines
12 KiB
Go
426 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"veza-backend-api/internal/models"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// PlaybackExportService gère l'export des analytics de lecture
|
|
// T0367: Create Playback Analytics Export Service
|
|
type PlaybackExportService struct {
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewPlaybackExportService crée un nouveau service d'export d'analytics
|
|
func NewPlaybackExportService(logger *zap.Logger) *PlaybackExportService {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &PlaybackExportService{
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// ExportFormat représente le format d'export
|
|
type ExportFormat string
|
|
|
|
const (
|
|
FormatCSV ExportFormat = "csv"
|
|
FormatJSON ExportFormat = "json"
|
|
)
|
|
|
|
// ExportCSV exporte les analytics en format CSV
|
|
// T0367: Create Playback Analytics Export Service
|
|
func (s *PlaybackExportService) ExportCSV(analytics []models.PlaybackAnalytics, filename string) error {
|
|
if len(analytics) == 0 {
|
|
return fmt.Errorf("no analytics data to export")
|
|
}
|
|
|
|
// Créer le répertoire parent si nécessaire
|
|
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
file, err := os.Create(filename)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
|
|
// Écrire l'en-tête CSV
|
|
header := []string{
|
|
"ID",
|
|
"Track ID",
|
|
"User ID",
|
|
"Play Time (seconds)",
|
|
"Pause Count",
|
|
"Seek Count",
|
|
"Completion Rate (%)",
|
|
"Started At",
|
|
"Ended At",
|
|
"Created At",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return fmt.Errorf("failed to write CSV header: %w", err)
|
|
}
|
|
|
|
// Écrire les données
|
|
for _, a := range analytics {
|
|
endedAt := ""
|
|
if a.EndedAt != nil {
|
|
endedAt = a.EndedAt.Format(time.RFC3339)
|
|
}
|
|
|
|
row := []string{
|
|
a.ID.String(), // UUID as string
|
|
a.TrackID.String(), // UUID as string
|
|
a.UserID.String(), // UUID as string
|
|
fmt.Sprintf("%d", a.PlayTime),
|
|
fmt.Sprintf("%d", a.PauseCount),
|
|
fmt.Sprintf("%d", a.SeekCount),
|
|
fmt.Sprintf("%.2f", a.CompletionRate),
|
|
a.StartedAt.Format(time.RFC3339),
|
|
endedAt,
|
|
a.CreatedAt.Format(time.RFC3339),
|
|
}
|
|
if err := writer.Write(row); err != nil {
|
|
return fmt.Errorf("failed to write CSV row: %w", err)
|
|
}
|
|
}
|
|
|
|
s.logger.Info("Analytics exported to CSV",
|
|
zap.String("filename", filename),
|
|
zap.Int("count", len(analytics)))
|
|
|
|
return nil
|
|
}
|
|
|
|
// ExportJSON exporte les analytics en format JSON
|
|
// T0367: Create Playback Analytics Export Service
|
|
func (s *PlaybackExportService) ExportJSON(analytics []models.PlaybackAnalytics, filename string) error {
|
|
if len(analytics) == 0 {
|
|
return fmt.Errorf("no analytics data to export")
|
|
}
|
|
|
|
// Créer le répertoire parent si nécessaire
|
|
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
data, err := json.MarshalIndent(analytics, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal JSON: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(filename, data, 0644); err != nil {
|
|
return fmt.Errorf("failed to write JSON file: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Analytics exported to JSON",
|
|
zap.String("filename", filename),
|
|
zap.Int("count", len(analytics)))
|
|
|
|
return nil
|
|
}
|
|
|
|
// ExportReport génère un rapport d'analytics avec statistiques agrégées
|
|
// T0367: Create Playback Analytics Export Service
|
|
func (s *PlaybackExportService) ExportReport(analytics []models.PlaybackAnalytics, filename string, format ExportFormat) error {
|
|
if len(analytics) == 0 {
|
|
return fmt.Errorf("no analytics data to export")
|
|
}
|
|
|
|
// Calculer les statistiques
|
|
stats := s.calculateReportStats(analytics)
|
|
|
|
// Générer le rapport selon le format
|
|
switch format {
|
|
case FormatCSV:
|
|
return s.exportReportCSV(analytics, stats, filename)
|
|
case FormatJSON:
|
|
return s.exportReportJSON(analytics, stats, filename)
|
|
default:
|
|
return fmt.Errorf("unsupported export format: %s", format)
|
|
}
|
|
}
|
|
|
|
// ReportStats représente les statistiques d'un rapport
|
|
type ReportStats struct {
|
|
TotalSessions int `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
|
|
CompletedSessions int `json:"completed_sessions"` // sessions with ≥95% completion
|
|
}
|
|
|
|
// calculateReportStats calcule les statistiques agrégées
|
|
func (s *PlaybackExportService) calculateReportStats(analytics []models.PlaybackAnalytics) ReportStats {
|
|
stats := ReportStats{
|
|
TotalSessions: len(analytics),
|
|
}
|
|
|
|
if len(analytics) == 0 {
|
|
return stats
|
|
}
|
|
|
|
var totalPlayTime int64
|
|
var totalPauses int64
|
|
var totalSeeks int64
|
|
var totalCompletion float64
|
|
completedSessions := 0
|
|
|
|
for _, a := range analytics {
|
|
totalPlayTime += int64(a.PlayTime)
|
|
totalPauses += int64(a.PauseCount)
|
|
totalSeeks += int64(a.SeekCount)
|
|
totalCompletion += a.CompletionRate
|
|
|
|
if a.CompletionRate >= 95.0 {
|
|
completedSessions++
|
|
}
|
|
}
|
|
|
|
stats.TotalPlayTime = totalPlayTime
|
|
stats.AveragePlayTime = float64(totalPlayTime) / float64(len(analytics))
|
|
stats.TotalPauses = totalPauses
|
|
stats.AveragePauses = float64(totalPauses) / float64(len(analytics))
|
|
stats.TotalSeeks = totalSeeks
|
|
stats.AverageSeeks = float64(totalSeeks) / float64(len(analytics))
|
|
stats.AverageCompletion = totalCompletion / float64(len(analytics))
|
|
stats.CompletedSessions = completedSessions
|
|
|
|
// Completion rate (sessions with >90% completion)
|
|
sessionsOver90 := 0
|
|
for _, a := range analytics {
|
|
if a.CompletionRate >= 90.0 {
|
|
sessionsOver90++
|
|
}
|
|
}
|
|
if len(analytics) > 0 {
|
|
stats.CompletionRate = float64(sessionsOver90) / float64(len(analytics)) * 100.0
|
|
}
|
|
|
|
return stats
|
|
}
|
|
|
|
// exportReportCSV exporte un rapport en CSV avec statistiques
|
|
func (s *PlaybackExportService) exportReportCSV(analytics []models.PlaybackAnalytics, stats ReportStats, filename string) error {
|
|
// Créer le répertoire parent si nécessaire
|
|
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
file, err := os.Create(filename)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
writer := csv.NewWriter(file)
|
|
defer writer.Flush()
|
|
|
|
// Écrire les statistiques
|
|
statsHeader := []string{"Statistic", "Value"}
|
|
if err := writer.Write(statsHeader); err != nil {
|
|
return fmt.Errorf("failed to write stats header: %w", err)
|
|
}
|
|
|
|
statsRows := [][]string{
|
|
{"Total Sessions", fmt.Sprintf("%d", stats.TotalSessions)},
|
|
{"Total Play Time (seconds)", fmt.Sprintf("%d", stats.TotalPlayTime)},
|
|
{"Average Play Time (seconds)", fmt.Sprintf("%.2f", stats.AveragePlayTime)},
|
|
{"Total Pauses", fmt.Sprintf("%d", stats.TotalPauses)},
|
|
{"Average Pauses", fmt.Sprintf("%.2f", stats.AveragePauses)},
|
|
{"Total Seeks", fmt.Sprintf("%d", stats.TotalSeeks)},
|
|
{"Average Seeks", fmt.Sprintf("%.2f", stats.AverageSeeks)},
|
|
{"Average Completion (%)", fmt.Sprintf("%.2f", stats.AverageCompletion)},
|
|
{"Completion Rate (%)", fmt.Sprintf("%.2f", stats.CompletionRate)},
|
|
{"Completed Sessions (≥95%)", fmt.Sprintf("%d", stats.CompletedSessions)},
|
|
}
|
|
|
|
for _, row := range statsRows {
|
|
if err := writer.Write(row); err != nil {
|
|
return fmt.Errorf("failed to write stats row: %w", err)
|
|
}
|
|
}
|
|
|
|
// Ligne vide
|
|
if err := writer.Write([]string{}); err != nil {
|
|
return fmt.Errorf("failed to write empty row: %w", err)
|
|
}
|
|
|
|
// Écrire l'en-tête des données
|
|
header := []string{
|
|
"ID",
|
|
"Track ID",
|
|
"User ID",
|
|
"Play Time (seconds)",
|
|
"Pause Count",
|
|
"Seek Count",
|
|
"Completion Rate (%)",
|
|
"Started At",
|
|
"Ended At",
|
|
"Created At",
|
|
}
|
|
if err := writer.Write(header); err != nil {
|
|
return fmt.Errorf("failed to write CSV header: %w", err)
|
|
}
|
|
|
|
// Écrire les données
|
|
for _, a := range analytics {
|
|
endedAt := ""
|
|
if a.EndedAt != nil {
|
|
endedAt = a.EndedAt.Format(time.RFC3339)
|
|
}
|
|
|
|
row := []string{
|
|
a.ID.String(), // UUID as string
|
|
a.TrackID.String(), // UUID as string
|
|
a.UserID.String(), // UUID as string
|
|
fmt.Sprintf("%d", a.PlayTime),
|
|
fmt.Sprintf("%d", a.PauseCount),
|
|
fmt.Sprintf("%d", a.SeekCount),
|
|
fmt.Sprintf("%.2f", a.CompletionRate),
|
|
a.StartedAt.Format(time.RFC3339),
|
|
endedAt,
|
|
a.CreatedAt.Format(time.RFC3339),
|
|
}
|
|
if err := writer.Write(row); err != nil {
|
|
return fmt.Errorf("failed to write CSV row: %w", err)
|
|
}
|
|
}
|
|
|
|
s.logger.Info("Analytics report exported to CSV",
|
|
zap.String("filename", filename),
|
|
zap.Int("count", len(analytics)))
|
|
|
|
return nil
|
|
}
|
|
|
|
// exportReportJSON exporte un rapport en JSON avec statistiques
|
|
func (s *PlaybackExportService) exportReportJSON(analytics []models.PlaybackAnalytics, stats ReportStats, filename string) error {
|
|
// Créer le répertoire parent si nécessaire
|
|
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
// Structure du rapport
|
|
report := map[string]interface{}{
|
|
"generated_at": time.Now().Format(time.RFC3339),
|
|
"statistics": stats,
|
|
"analytics": analytics,
|
|
}
|
|
|
|
data, err := json.MarshalIndent(report, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal JSON: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(filename, data, 0644); err != nil {
|
|
return fmt.Errorf("failed to write JSON file: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Analytics report exported to JSON",
|
|
zap.String("filename", filename),
|
|
zap.Int("count", len(analytics)))
|
|
|
|
return nil
|
|
}
|
|
|
|
// ExportToWriter exporte les analytics vers un writer (pour streaming HTTP)
|
|
func (s *PlaybackExportService) ExportToWriter(analytics []models.PlaybackAnalytics, format ExportFormat, writer interface{}) error {
|
|
switch format {
|
|
case FormatCSV:
|
|
return s.exportCSVToWriter(analytics, writer)
|
|
case FormatJSON:
|
|
return s.exportJSONToWriter(analytics, writer)
|
|
default:
|
|
return fmt.Errorf("unsupported export format: %s", format)
|
|
}
|
|
}
|
|
|
|
// exportCSVToWriter exporte en CSV vers un writer
|
|
func (s *PlaybackExportService) exportCSVToWriter(analytics []models.PlaybackAnalytics, writer interface{}) error {
|
|
// Cette méthode peut être étendue pour supporter différents types de writers
|
|
// Pour l'instant, on retourne une erreur si le writer n'est pas un *os.File
|
|
file, ok := writer.(*os.File)
|
|
if !ok {
|
|
return fmt.Errorf("writer must be *os.File for CSV export")
|
|
}
|
|
|
|
csvWriter := csv.NewWriter(file)
|
|
defer csvWriter.Flush()
|
|
|
|
// Écrire l'en-tête
|
|
header := []string{
|
|
"ID",
|
|
"Track ID",
|
|
"User ID",
|
|
"Play Time (seconds)",
|
|
"Pause Count",
|
|
"Seek Count",
|
|
"Completion Rate (%)",
|
|
"Started At",
|
|
"Ended At",
|
|
"Created At",
|
|
}
|
|
if err := csvWriter.Write(header); err != nil {
|
|
return fmt.Errorf("failed to write CSV header: %w", err)
|
|
}
|
|
|
|
// Écrire les données
|
|
for _, a := range analytics {
|
|
endedAt := ""
|
|
if a.EndedAt != nil {
|
|
endedAt = a.EndedAt.Format(time.RFC3339)
|
|
}
|
|
|
|
row := []string{
|
|
a.ID.String(), // UUID as string
|
|
a.TrackID.String(), // UUID as string
|
|
a.UserID.String(), // UUID as string
|
|
fmt.Sprintf("%d", a.PlayTime),
|
|
fmt.Sprintf("%d", a.PauseCount),
|
|
fmt.Sprintf("%d", a.SeekCount),
|
|
fmt.Sprintf("%.2f", a.CompletionRate),
|
|
a.StartedAt.Format(time.RFC3339),
|
|
endedAt,
|
|
a.CreatedAt.Format(time.RFC3339),
|
|
}
|
|
if err := csvWriter.Write(row); err != nil {
|
|
return fmt.Errorf("failed to write CSV row: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// exportJSONToWriter exporte en JSON vers un writer
|
|
func (s *PlaybackExportService) exportJSONToWriter(analytics []models.PlaybackAnalytics, writer interface{}) error {
|
|
// Cette méthode peut être étendue pour supporter différents types de writers
|
|
// Pour l'instant, on retourne une erreur si le writer n'est pas un *os.File
|
|
file, ok := writer.(*os.File)
|
|
if !ok {
|
|
return fmt.Errorf("writer must be *os.File for JSON export")
|
|
}
|
|
|
|
encoder := json.NewEncoder(file)
|
|
encoder.SetIndent("", " ")
|
|
return encoder.Encode(analytics)
|
|
}
|