282 lines
8.5 KiB
Go
282 lines
8.5 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
|
|
}
|
|
|
|
// GetDB retourne la connexion DB (pour accès direct dans les handlers si nécessaire)
|
|
func (s *AnalyticsService) GetDB() *gorm.DB {
|
|
return s.db
|
|
}
|
|
|
|
// 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 (optimisé: 1 requête au lieu de 5)
|
|
func (s *AnalyticsService) GetTrackStats(ctx context.Context, trackID uuid.UUID) (*types.TrackStats, error) {
|
|
var result struct {
|
|
TrackDuration int `gorm:"column:track_duration"`
|
|
TotalPlays int64 `gorm:"column:total_plays"`
|
|
UniqueListeners int64 `gorm:"column:unique_listeners"`
|
|
AvgDuration float64 `gorm:"column:avg_duration"`
|
|
CompletedPlays int64 `gorm:"column:completed_plays"`
|
|
}
|
|
|
|
tx := s.db.WithContext(ctx).Raw(`
|
|
SELECT
|
|
t.duration AS track_duration,
|
|
CAST(COUNT(tp.id) AS INTEGER) AS total_plays,
|
|
CAST(COUNT(DISTINCT CASE WHEN tp.user_id IS NOT NULL THEN tp.user_id END) AS INTEGER) AS unique_listeners,
|
|
COALESCE(AVG(tp.duration), 0) AS avg_duration,
|
|
CAST(COUNT(CASE WHEN t.duration > 0 AND tp.duration >= t.duration * 0.9 THEN 1 END) AS INTEGER) AS completed_plays
|
|
FROM tracks t
|
|
LEFT JOIN track_plays tp ON tp.track_id = t.id
|
|
WHERE t.id = ?
|
|
GROUP BY t.id, t.duration
|
|
`, trackID).Scan(&result)
|
|
if tx.Error != nil {
|
|
return nil, fmt.Errorf("failed to get track stats: %w", tx.Error)
|
|
}
|
|
if tx.RowsAffected == 0 {
|
|
return nil, errors.New("track not found")
|
|
}
|
|
|
|
stats := &types.TrackStats{
|
|
TotalPlays: result.TotalPlays,
|
|
UniqueListeners: result.UniqueListeners,
|
|
AverageDuration: result.AvgDuration,
|
|
CompletionRate: 0,
|
|
}
|
|
if result.TotalPlays > 0 {
|
|
stats.CompletionRate = float64(result.CompletedPlays) / float64(result.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
|
|
}
|