289 lines
8.8 KiB
Go
289 lines
8.8 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"github.com/google/uuid"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
"veza-backend-api/internal/models"
|
|
"veza-backend-api/internal/types"
|
|
)
|
|
|
|
// AnalyticsService gère les analytics de lecture de tracks
|
|
type AnalyticsService struct {
|
|
db *gorm.DB
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewAnalyticsService crée un nouveau service d'analytics
|
|
func NewAnalyticsService(db *gorm.DB, logger *zap.Logger) *AnalyticsService {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &AnalyticsService{
|
|
db: db,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// TrackStats est maintenant défini dans internal/types/stats.go
|
|
// Import: veza-backend-api/internal/types
|
|
|
|
// PlayTimePoint représente un point de données temporel pour les graphiques
|
|
type PlayTimePoint struct {
|
|
Date time.Time `json:"date"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
|
|
// TopTrack représente un track dans le classement
|
|
type TopTrack struct {
|
|
TrackID uuid.UUID `json:"track_id"` // Changed to uuid.UUID
|
|
Title string `json:"title"`
|
|
Artist string `json:"artist"`
|
|
TotalPlays int64 `json:"total_plays"`
|
|
UniqueListeners int64 `json:"unique_listeners"`
|
|
AverageDuration float64 `json:"average_duration"`
|
|
}
|
|
|
|
// UserStats est maintenant défini dans internal/types/stats.go
|
|
// Import: veza-backend-api/internal/types
|
|
|
|
// RecordPlay enregistre une lecture de track
|
|
// MIGRATION UUID: userID migré vers *uuid.UUID (nullable)
|
|
func (s *AnalyticsService) RecordPlay(ctx context.Context, trackID uuid.UUID, userID *uuid.UUID, duration int, device, ipAddress string) error {
|
|
// Vérifier que le track existe
|
|
var track models.Track
|
|
if err := s.db.WithContext(ctx).First(&track, "id = ?", trackID).Error; err != nil { // Updated query to use "id = ?" for UUID
|
|
if err == gorm.ErrRecordNotFound {
|
|
return errors.New("track not found")
|
|
}
|
|
return fmt.Errorf("failed to check track: %w", err)
|
|
}
|
|
|
|
play := &models.TrackPlay{
|
|
TrackID: trackID,
|
|
UserID: userID,
|
|
Duration: duration,
|
|
PlayedAt: time.Now(),
|
|
Device: device,
|
|
IPAddress: ipAddress,
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Create(play).Error; err != nil {
|
|
return fmt.Errorf("failed to record play: %w", err)
|
|
}
|
|
|
|
s.logger.Info("Track play recorded",
|
|
zap.Any("track_id", trackID), // Changed to zap.Any for uuid.UUID
|
|
zap.Any("user_id", userID),
|
|
zap.Int("duration", duration),
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetTrackStats récupère les statistiques d'un track
|
|
func (s *AnalyticsService) GetTrackStats(ctx context.Context, trackID uuid.UUID) (*types.TrackStats, error) { // Changed trackID to uuid.UUID
|
|
var stats types.TrackStats
|
|
|
|
// Vérifier que le track existe
|
|
var track models.Track
|
|
if err := s.db.WithContext(ctx).First(&track, "id = ?", trackID).Error; err != nil { // Updated query
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, errors.New("track not found")
|
|
}
|
|
return nil, fmt.Errorf("failed to get track: %w", err)
|
|
}
|
|
|
|
// Total plays
|
|
if err := s.db.WithContext(ctx).Model(&models.TrackPlay{}).
|
|
Where("track_id = ?", trackID).
|
|
Count(&stats.TotalPlays).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to count total plays: %w", err)
|
|
}
|
|
|
|
// Unique listeners (distinct user_id, en excluant NULL)
|
|
if err := s.db.WithContext(ctx).Model(&models.TrackPlay{}).
|
|
Where("track_id = ? AND user_id IS NOT NULL", trackID).
|
|
Distinct("user_id").
|
|
Count(&stats.UniqueListeners).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to count unique listeners: %w", err)
|
|
}
|
|
|
|
// Average duration
|
|
var avgDuration float64
|
|
if err := s.db.WithContext(ctx).Model(&models.TrackPlay{}).
|
|
Where("track_id = ?", trackID).
|
|
Select("COALESCE(AVG(duration), 0)").
|
|
Scan(&avgDuration).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to calculate average duration: %w", err)
|
|
}
|
|
stats.AverageDuration = avgDuration
|
|
|
|
// Completion rate (90% de la durée du track)
|
|
if track.Duration > 0 && stats.TotalPlays > 0 {
|
|
var completedPlays int64
|
|
completionThreshold := int(float64(track.Duration) * 0.9)
|
|
if err := s.db.WithContext(ctx).Model(&models.TrackPlay{}).
|
|
Where("track_id = ? AND duration >= ?", trackID, completionThreshold).
|
|
Count(&completedPlays).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to count completed plays: %w", err)
|
|
}
|
|
stats.CompletionRate = float64(completedPlays) / float64(stats.TotalPlays) * 100
|
|
}
|
|
|
|
return &stats, nil
|
|
}
|
|
|
|
// GetPlaysOverTime récupère les lectures sur une période pour un graphique temporel
|
|
func (s *AnalyticsService) GetPlaysOverTime(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, interval string) ([]PlayTimePoint, error) { // Changed trackID to uuid.UUID
|
|
// Vérifier que le track existe
|
|
var track models.Track
|
|
if err := s.db.WithContext(ctx).First(&track, "id = ?", trackID).Error; err != nil { // Updated query
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, errors.New("track not found")
|
|
}
|
|
return nil, fmt.Errorf("failed to get track: %w", err)
|
|
}
|
|
|
|
// Requête SQL pour grouper par intervalle
|
|
// Utiliser strftime pour SQLite (compatible avec la plupart des bases de données)
|
|
var dateFormatSQLite string
|
|
switch interval {
|
|
case "hour":
|
|
dateFormatSQLite = "%Y-%m-%d %H:00:00"
|
|
case "day":
|
|
dateFormatSQLite = "%Y-%m-%d"
|
|
case "week":
|
|
dateFormatSQLite = "%Y-W%W"
|
|
case "month":
|
|
dateFormatSQLite = "%Y-%m"
|
|
default:
|
|
dateFormatSQLite = "%Y-%m-%d"
|
|
}
|
|
|
|
var sqliteResults []struct {
|
|
Date string `gorm:"column:date"`
|
|
Count int64 `gorm:"column:count"`
|
|
}
|
|
|
|
if err := s.db.WithContext(ctx).Model(&models.TrackPlay{}).
|
|
Select(fmt.Sprintf("strftime('%s', played_at) as date, COUNT(*) as count", dateFormatSQLite)).
|
|
Where("track_id = ? AND played_at >= ? AND played_at <= ?", trackID, startDate, endDate).
|
|
Group("date").
|
|
Order("date ASC").
|
|
Scan(&sqliteResults).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to get plays over time: %w", err)
|
|
}
|
|
|
|
// Convertir les résultats
|
|
points := make([]PlayTimePoint, len(sqliteResults))
|
|
for i, r := range sqliteResults {
|
|
// Essayer de parser avec différents formats
|
|
parsedDate, err := time.Parse("2006-01-02 15:04:05", r.Date)
|
|
if err != nil {
|
|
parsedDate, err = time.Parse("2006-01-02", r.Date)
|
|
if err != nil {
|
|
parsedDate, err = time.Parse("2006-01", r.Date)
|
|
if err != nil {
|
|
parsedDate, _ = time.Parse("2006-W01", r.Date)
|
|
}
|
|
}
|
|
}
|
|
points[i] = PlayTimePoint{
|
|
Date: parsedDate,
|
|
Count: r.Count,
|
|
}
|
|
}
|
|
|
|
return points, nil
|
|
}
|
|
|
|
// GetTopTracks récupère les tracks les plus écoutés
|
|
func (s *AnalyticsService) GetTopTracks(ctx context.Context, limit int, startDate, endDate *time.Time) ([]TopTrack, error) {
|
|
if limit <= 0 {
|
|
limit = 10
|
|
}
|
|
if limit > 100 {
|
|
limit = 100
|
|
}
|
|
|
|
query := s.db.WithContext(ctx).Model(&models.TrackPlay{}).
|
|
Select(`
|
|
track_plays.track_id,
|
|
tracks.title,
|
|
tracks.artist,
|
|
COUNT(*) as total_plays,
|
|
COUNT(DISTINCT track_plays.user_id) as unique_listeners,
|
|
COALESCE(AVG(track_plays.duration), 0) as average_duration
|
|
`).
|
|
Joins("JOIN tracks ON tracks.id = track_plays.track_id").
|
|
Group("track_plays.track_id, tracks.title, tracks.artist")
|
|
|
|
// Filtrer par date si fourni
|
|
if startDate != nil {
|
|
query = query.Where("track_plays.played_at >= ?", *startDate)
|
|
}
|
|
if endDate != nil {
|
|
query = query.Where("track_plays.played_at <= ?", *endDate)
|
|
}
|
|
|
|
query = query.Order("total_plays DESC").Limit(limit)
|
|
|
|
var results []TopTrack
|
|
if err := query.Scan(&results).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to get top tracks: %w", err)
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// GetUserStats récupère les statistiques d'un utilisateur
|
|
func (s *AnalyticsService) GetUserStats(ctx context.Context, userID uuid.UUID) (*types.UserStats, error) {
|
|
// 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, errors.New("user not found")
|
|
}
|
|
return nil, fmt.Errorf("failed to get user: %w", err)
|
|
}
|
|
|
|
var stats types.UserStats
|
|
|
|
// Total plays
|
|
if err := s.db.WithContext(ctx).Model(&models.TrackPlay{}).
|
|
Where("user_id = ?", userID).
|
|
Count(&stats.TotalPlays).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to count total plays: %w", err)
|
|
}
|
|
|
|
// Unique tracks
|
|
if err := s.db.WithContext(ctx).Model(&models.TrackPlay{}).
|
|
Where("user_id = ?", userID).
|
|
Distinct("track_id").
|
|
Count(&stats.UniqueTracks).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to count unique tracks: %w", err)
|
|
}
|
|
|
|
// Total duration
|
|
var totalDuration int64
|
|
if err := s.db.WithContext(ctx).Model(&models.TrackPlay{}).
|
|
Where("user_id = ?", userID).
|
|
Select("COALESCE(SUM(duration), 0)").
|
|
Scan(&totalDuration).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to calculate total duration: %w", err)
|
|
}
|
|
stats.TotalDuration = totalDuration
|
|
|
|
// Average duration
|
|
if stats.TotalPlays > 0 {
|
|
stats.AverageDuration = float64(totalDuration) / float64(stats.TotalPlays)
|
|
}
|
|
|
|
return &stats, nil
|
|
}
|