veza/veza-backend-api/internal/core/track/service.go
2025-12-16 11:23:49 -05:00

1058 lines
33 KiB
Go

package track
import (
"context"
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings" // Removed strconv
"time" // MOD-P2-008: Ajouté pour timeout asynchrone
"veza-backend-api/internal/models"
"veza-backend-api/internal/types"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
)
// Constantes pour les quotas utilisateur
const (
MaxTracksPerUser = 1000 // Nombre maximum de tracks par utilisateur
MaxStoragePerUser = 100 * 1024 * 1024 * 1024 // 100GB par utilisateur
)
// Types d'erreurs spécifiques pour les tracks
var (
// ErrInvalidTrackFormat est retourné quand le format du fichier est invalide
ErrInvalidTrackFormat = errors.New("invalid track format")
// ErrTrackTooLarge est retourné quand le fichier dépasse la taille maximale
ErrTrackTooLarge = errors.New("track file too large")
// ErrTrackQuotaExceeded est retourné quand l'utilisateur a atteint son quota de tracks
ErrTrackQuotaExceeded = errors.New("track quota exceeded")
// ErrStorageQuotaExceeded est retourné quand l'utilisateur a atteint son quota de stockage
ErrStorageQuotaExceeded = errors.New("storage quota exceeded")
// ErrTrackNotFound est retourné quand un track n'est pas trouvé
ErrTrackNotFound = errors.New("track not found")
// ErrNetworkError est retourné en cas d'erreur réseau (timeout, connexion)
ErrNetworkError = errors.New("network error")
// ErrStorageError est retourné en cas d'erreur de stockage
ErrStorageError = errors.New("storage error")
// ErrForbidden est retourné quand l'utilisateur n'a pas la permission d'effectuer l'action
ErrForbidden = errors.New("forbidden")
)
// TrackService gère les opérations sur les tracks
type TrackService struct {
db *gorm.DB
logger *zap.Logger
uploadDir string
maxFileSize int64
}
// NewTrackService crée un nouveau service de tracks
func NewTrackService(db *gorm.DB, logger *zap.Logger, uploadDir string) *TrackService {
if uploadDir == "" {
uploadDir = "uploads/tracks"
}
return &TrackService{
db: db,
logger: logger,
uploadDir: uploadDir,
maxFileSize: 100 * 1024 * 1024, // 100MB
}
}
// ValidateTrackFile valide le format et la taille d'un fichier audio
func (s *TrackService) ValidateTrackFile(fileHeader *multipart.FileHeader) error {
// Valider la taille
if fileHeader.Size > s.maxFileSize {
return fmt.Errorf("%w: file size exceeds maximum allowed size of 100MB", ErrTrackTooLarge)
}
if fileHeader.Size == 0 {
return fmt.Errorf("%w: file is empty", ErrInvalidTrackFormat)
}
// Valider l'extension
ext := strings.ToLower(filepath.Ext(fileHeader.Filename))
allowedExtensions := []string{".mp3", ".flac", ".wav", ".ogg", ".m4a", ".aac"}
isValidExt := false
for _, allowedExt := range allowedExtensions {
if ext == allowedExt {
isValidExt = true
break
}
}
if !isValidExt {
return fmt.Errorf("%w: invalid file format. Allowed formats: MP3, FLAC, WAV, OGG", ErrInvalidTrackFormat)
}
// Valider le type MIME en ouvrant le fichier
file, err := fileHeader.Open()
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Lire les premiers bytes pour vérifier le magic number
header := make([]byte, 12)
n, err := file.Read(header)
if err != nil && err != io.EOF {
return fmt.Errorf("failed to read file header: %w", err)
}
if n < 4 {
return fmt.Errorf("file too small to validate")
}
// Vérifier les magic numbers pour les formats audio
isValidFormat := false
headerStr := string(header[:n])
// MP3: ID3v2 (starts with "ID3") or MPEG frame sync (0xFF 0xFB/E/F)
if strings.HasPrefix(headerStr, "ID3") || (header[0] == 0xFF && (header[1]&0xE0) == 0xE0) {
isValidFormat = true
}
// FLAC: "fLaC"
if strings.HasPrefix(headerStr, "fLaC") {
isValidFormat = true
}
// WAV: "RIFF" followed by "WAVE"
if strings.HasPrefix(headerStr, "RIFF") && len(headerStr) >= 12 && string(header[8:12]) == "WAVE" {
isValidFormat = true
}
// OGG: "OggS"
if strings.HasPrefix(headerStr, "OggS") {
isValidFormat = true
}
// M4A/AAC: "ftyp" avec "M4A" ou "mp4"
if strings.Contains(headerStr, "ftyp") && (strings.Contains(headerStr, "M4A") || strings.Contains(headerStr, "mp4")) {
isValidFormat = true
}
if !isValidFormat {
return fmt.Errorf("%w: invalid audio file format", ErrInvalidTrackFormat)
}
return nil
}
// UploadTrack upload un fichier audio et crée un enregistrement Track en base
// MOD-P2-008: Implémentation asynchrone - crée le Track immédiatement et lance la copie en goroutine
// Retourne le Track avec Status=Uploading, la copie se fait en arrière-plan
func (s *TrackService) UploadTrack(ctx context.Context, userID uuid.UUID, fileHeader *multipart.FileHeader) (*models.Track, error) {
// Vérifier le quota utilisateur
if err := s.CheckUserQuota(ctx, userID, fileHeader.Size); err != nil {
return nil, err
}
// Valider le fichier
if err := s.ValidateTrackFile(fileHeader); err != nil {
return nil, err
}
// Créer le répertoire d'upload s'il n'existe pas
if err := os.MkdirAll(s.uploadDir, 0755); err != nil {
return nil, fmt.Errorf("%w: failed to create upload directory: %w", ErrStorageError, err)
}
// Générer un nom de fichier unique
timestamp := uuid.New()
ext := filepath.Ext(fileHeader.Filename)
filename := fmt.Sprintf("%d_%d%s", userID, timestamp, ext)
filePath := filepath.Join(s.uploadDir, filename)
// Déterminer le format depuis l'extension
format := strings.TrimPrefix(strings.ToUpper(ext), ".")
if format == "M4A" {
format = "AAC"
}
// Extraire le titre depuis le nom de fichier (sans extension)
title := strings.TrimSuffix(fileHeader.Filename, ext)
// MOD-P2-008: Créer l'enregistrement Track en base AVANT la copie (sémantique asynchrone)
// Le fichier n'existe pas encore, mais on crée l'enregistrement pour traçabilité
// FileID est NULL temporairement (sera mis à jour après création du fichier)
track := &models.Track{
UserID: userID,
FileID: nil, // NULL temporairement - sera mis à jour après création fichier
Title: title,
FilePath: filePath,
FileSize: fileHeader.Size,
Format: format,
Duration: 0, // Sera mis à jour lors du traitement asynchrone
IsPublic: true,
Status: models.TrackStatusUploading,
StatusMessage: "Upload started",
}
if err := s.db.WithContext(ctx).Create(track).Error; err != nil {
return nil, fmt.Errorf("failed to create track record: %w", err)
}
// MOD-P2-008: Lancer la copie fichier en goroutine avec suivi (context + cancellation)
// La goroutine mettra à jour le Status quand terminé
go s.copyFileAsync(ctx, track.ID, fileHeader, filePath, userID)
s.logger.Info("Track upload initiated (async)",
zap.String("track_id", track.ID.String()),
zap.String("user_id", userID.String()),
zap.String("filename", filename),
zap.Int64("file_size", fileHeader.Size),
)
return track, nil
}
// copyFileAsync copie le fichier de manière asynchrone et met à jour le Status du Track
// MOD-P2-008: Goroutine suivie avec context + cancellation + nettoyage en cas d'erreur
func (s *TrackService) copyFileAsync(ctx context.Context, trackID uuid.UUID, fileHeader *multipart.FileHeader, filePath string, userID uuid.UUID) {
// Créer un contexte avec timeout pour la copie (5 minutes max)
copyCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Ouvrir le fichier source
src, err := fileHeader.Open()
if err != nil {
s.updateTrackStatus(copyCtx, trackID, models.TrackStatusFailed, fmt.Sprintf("Failed to open uploaded file: %v", err))
s.cleanupFailedUpload(filePath, trackID, "failed to open source file")
return
}
defer src.Close()
// Créer le fichier de destination
dst, err := os.Create(filePath)
if err != nil {
s.updateTrackStatus(copyCtx, trackID, models.TrackStatusFailed, fmt.Sprintf("Failed to create destination file: %v", err))
s.cleanupFailedUpload(filePath, trackID, "failed to create destination file")
return
}
defer dst.Close()
// Copier le fichier avec gestion d'erreurs
bytesWritten, err := io.Copy(dst, src)
if err != nil {
s.updateTrackStatus(copyCtx, trackID, models.TrackStatusFailed, fmt.Sprintf("Failed to save file: %v", err))
s.cleanupFailedUpload(filePath, trackID, fmt.Sprintf("copy failed: %v", err))
return
}
// Vérifier si le contexte a été annulé
select {
case <-copyCtx.Done():
s.updateTrackStatus(copyCtx, trackID, models.TrackStatusFailed, fmt.Sprintf("Upload cancelled: %v", copyCtx.Err()))
s.cleanupFailedUpload(filePath, trackID, "upload cancelled")
return
default:
// Continuer
}
// Vérifier que tous les bytes ont été copiés
if bytesWritten != fileHeader.Size {
s.updateTrackStatus(copyCtx, trackID, models.TrackStatusFailed, fmt.Sprintf("Incomplete copy: %d/%d bytes", bytesWritten, fileHeader.Size))
s.cleanupFailedUpload(filePath, trackID, fmt.Sprintf("incomplete copy: %d/%d bytes", bytesWritten, fileHeader.Size))
return
}
// Copie réussie - mettre à jour le Status
s.updateTrackStatus(copyCtx, trackID, models.TrackStatusProcessing, "File uploaded, processing...")
s.logger.Info("Track file copied successfully (async)",
zap.String("track_id", trackID.String()),
zap.String("user_id", userID.String()),
zap.Int64("bytes_written", bytesWritten),
zap.String("file_path", filePath),
)
}
// updateTrackStatus met à jour le Status et StatusMessage d'un Track
// MOD-P2-008: Helper pour mettre à jour le Status de manière thread-safe
func (s *TrackService) updateTrackStatus(ctx context.Context, trackID uuid.UUID, status models.TrackStatus, message string) {
if err := s.db.WithContext(ctx).Model(&models.Track{}).
Where("id = ?", trackID).
Updates(map[string]interface{}{
"status": status,
"status_message": message,
}).Error; err != nil {
s.logger.Error("Failed to update track status",
zap.String("track_id", trackID.String()),
zap.String("status", string(status)),
zap.String("message", message),
zap.Error(err),
)
} else {
s.logger.Info("Track status updated",
zap.String("track_id", trackID.String()),
zap.String("status", string(status)),
zap.String("message", message),
)
}
}
// cleanupFailedUpload nettoie le fichier et le Track en cas d'échec
// MOD-P2-008: Nettoyage automatique en cas d'erreur
func (s *TrackService) cleanupFailedUpload(filePath string, trackID uuid.UUID, reason string) {
// Supprimer le fichier s'il existe
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
s.logger.Warn("Failed to cleanup file after upload failure",
zap.String("file_path", filePath),
zap.String("track_id", trackID.String()),
zap.String("reason", reason),
zap.Error(err),
)
}
s.logger.Info("Cleaned up failed upload",
zap.String("track_id", trackID.String()),
zap.String("file_path", filePath),
zap.String("reason", reason),
)
}
// CreateTrackFromPath crée un track à partir d'un fichier déjà sauvegardé
func (s *TrackService) CreateTrackFromPath(ctx context.Context, userID uuid.UUID, filePath, filename string, fileSize int64, format string) (*models.Track, error) {
ext := filepath.Ext(filename)
title := strings.TrimSuffix(filename, ext)
track := &models.Track{
UserID: userID,
Title: title,
FilePath: filePath,
FileSize: fileSize,
Format: format,
Duration: 0, // Sera mis à jour lors du traitement asynchrone
IsPublic: true,
Status: models.TrackStatusUploading,
StatusMessage: "Upload completed",
}
if err := s.db.WithContext(ctx).Create(track).Error; err != nil {
return nil, fmt.Errorf("failed to create track record: %w", err)
}
s.logger.Info("Track created from path",
zap.String("track_id", track.ID.String()),
zap.String("user_id", userID.String()),
zap.String("file_path", filePath),
zap.Int64("file_size", fileSize),
)
return track, nil
}
// UserQuota représente les informations de quota d'un utilisateur
type UserQuota struct {
TracksCount int64 `json:"tracks_count"`
TracksLimit int64 `json:"tracks_limit"`
StorageUsed int64 `json:"storage_used"` // bytes
StorageLimit int64 `json:"storage_limit"` // bytes
}
// CheckUserQuota vérifie si l'utilisateur peut uploader un fichier selon son quota
func (s *TrackService) CheckUserQuota(ctx context.Context, userID uuid.UUID, fileSize int64) error {
var trackCount int64
// MOD-P2-008: Utiliser creator_id (nom de colonne réel) au lieu de user_id
if err := s.db.WithContext(ctx).Model(&models.Track{}).Where("creator_id = ?", userID).Count(&trackCount).Error; err != nil {
return fmt.Errorf("failed to check track count: %w", err)
}
if trackCount >= MaxTracksPerUser {
return ErrTrackQuotaExceeded
}
var totalSize int64
if err := s.db.WithContext(ctx).Model(&models.Track{}).
Where("creator_id = ?", userID).
Select("COALESCE(SUM(file_size), 0)").
Scan(&totalSize).Error; err != nil {
return fmt.Errorf("failed to check storage usage: %w", err)
}
if totalSize+fileSize > MaxStoragePerUser {
return ErrStorageQuotaExceeded
}
return nil
}
// GetUserQuota récupère les informations de quota d'un utilisateur
func (s *TrackService) GetUserQuota(ctx context.Context, userID uuid.UUID) (*UserQuota, error) {
var trackCount int64
if err := s.db.WithContext(ctx).Model(&models.Track{}).Where("creator_id = ?", userID).Count(&trackCount).Error; err != nil {
return nil, fmt.Errorf("failed to get track count: %w", err)
}
var totalSize int64
if err := s.db.WithContext(ctx).Model(&models.Track{}).
Where("creator_id = ?", userID).
Select("COALESCE(SUM(file_size), 0)").
Scan(&totalSize).Error; err != nil {
return nil, fmt.Errorf("failed to get storage usage: %w", err)
}
return &UserQuota{
TracksCount: trackCount,
TracksLimit: MaxTracksPerUser,
StorageUsed: totalSize,
StorageLimit: MaxStoragePerUser,
}, nil
}
// TrackListParams représente les paramètres de filtrage et pagination pour la liste des tracks
type TrackListParams struct {
Page int
Limit int
UserID *uuid.UUID
Genre *string
Format *string
SortBy string // "created_at", "title", "popularity"
SortOrder string // "asc", "desc"
}
// ListTracks récupère une liste de tracks avec pagination, filtres et tri
func (s *TrackService) ListTracks(ctx context.Context, params TrackListParams) ([]*models.Track, int64, error) {
// Créer la requête de base avec filtre sur le statut
query := s.db.WithContext(ctx).Model(&models.Track{}).Where("status = ?", models.TrackStatusCompleted)
// Appliquer les filtres
if params.UserID != nil {
query = query.Where("creator_id = ?", *params.UserID)
}
if params.Genre != nil && *params.Genre != "" {
query = query.Where("genre = ?", *params.Genre)
}
if params.Format != nil && *params.Format != "" {
query = query.Where("format = ?", *params.Format)
}
// Compter le total avant pagination
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count tracks: %w", err)
}
// Appliquer le tri
sortOrder := "DESC"
if params.SortOrder == "asc" {
sortOrder = "ASC"
}
// Valider et appliquer SortBy
sortBy := params.SortBy
if sortBy == "" {
sortBy = "created_at"
}
// Sécurité: valider que sortBy est un champ valide
validSortFields := map[string]bool{
"created_at": true,
"title": true,
"popularity": true,
}
if !validSortFields[sortBy] {
sortBy = "created_at"
}
// Pour "popularity", on utilise play_count + like_count
if sortBy == "popularity" {
query = query.Order(fmt.Sprintf("(play_count + like_count) %s", sortOrder))
} else {
query = query.Order(fmt.Sprintf("%s %s", sortBy, sortOrder))
}
// Appliquer la pagination
if params.Limit <= 0 {
params.Limit = 20 // Par défaut
}
if params.Limit > 100 {
params.Limit = 100 // Maximum
}
if params.Page <= 0 {
params.Page = 1
}
offset := (params.Page - 1) * params.Limit
query = query.Offset(offset).Limit(params.Limit)
// Exécuter la requête
var tracks []*models.Track
if err := query.Preload("User").Find(&tracks).Error; err != nil {
return nil, 0, fmt.Errorf("failed to list tracks: %w", err)
}
return tracks, total, nil
}
// GetTrackByID récupère un track par son ID
// MOD-P1-003: Preload User pour éviter N+1 queries si User est accédé plus tard
func (s *TrackService) GetTrackByID(ctx context.Context, trackID uuid.UUID) (*models.Track, error) { // Changed trackID to uuid.UUID
var track models.Track
if err := s.db.WithContext(ctx).
Preload("User").
First(&track, "id = ?", trackID).Error; err != nil { // Updated query
if err == gorm.ErrRecordNotFound {
return nil, ErrTrackNotFound
}
return nil, fmt.Errorf("failed to get track: %w", err)
}
return &track, nil
}
// UpdateTrackParams représente les paramètres de mise à jour d'un track
type UpdateTrackParams struct {
Title *string `json:"title"`
Artist *string `json:"artist"`
Album *string `json:"album"`
Genre *string `json:"genre"`
Year *int `json:"year"`
IsPublic *bool `json:"is_public"`
}
// UpdateTrack met à jour les métadonnées d'un track
func (s *TrackService) UpdateTrack(ctx context.Context, trackID uuid.UUID, userID uuid.UUID, params UpdateTrackParams) (*models.Track, error) { // Changed trackID to uuid.UUID
// Récupérer le track existant
track, err := s.GetTrackByID(ctx, trackID)
if err != nil {
return nil, err
}
// MOD-P1-003: Vérifier que l'utilisateur est propriétaire du track ou admin
// Check if user is admin (passed via context value)
isAdmin := false
if adminVal := ctx.Value("is_admin"); adminVal != nil {
if admin, ok := adminVal.(bool); ok {
isAdmin = admin
}
}
if track.UserID != userID && !isAdmin {
return nil, ErrForbidden
}
// Construire les mises à jour
updates := make(map[string]interface{})
if params.Title != nil {
if *params.Title == "" {
return nil, fmt.Errorf("title cannot be empty")
}
updates["title"] = *params.Title
}
if params.Artist != nil {
updates["artist"] = *params.Artist
}
if params.Album != nil {
updates["album"] = *params.Album
}
if params.Genre != nil {
updates["genre"] = *params.Genre
}
if params.Year != nil {
if *params.Year < 0 {
return nil, fmt.Errorf("year cannot be negative")
}
updates["year"] = *params.Year
}
if params.IsPublic != nil {
updates["is_public"] = *params.IsPublic
}
// Si aucune mise à jour n'est demandée
if len(updates) == 0 {
return track, nil
}
// Appliquer les mises à jour
if err := s.db.WithContext(ctx).Model(track).Updates(updates).Error; err != nil {
return nil, fmt.Errorf("failed to update track: %w", err)
}
// Recharger le track pour obtenir les valeurs mises à jour
updatedTrack, err := s.GetTrackByID(ctx, trackID)
if err != nil {
return nil, err
}
s.logger.Info("Track updated",
zap.Any("track_id", trackID), // Changed to zap.Any for uuid.UUID
zap.String("user_id", userID.String()),
zap.Any("updates", updates),
)
return updatedTrack, nil
}
// DeleteTrack supprime un track et son fichier physique
func (s *TrackService) DeleteTrack(ctx context.Context, trackID uuid.UUID, userID uuid.UUID) error { // Changed trackID to uuid.UUID
// Récupérer le track existant
track, err := s.GetTrackByID(ctx, trackID)
if err != nil {
return err
}
// MOD-P1-003: Vérifier que l'utilisateur est propriétaire du track ou admin
// Check if user is admin (passed via context value)
isAdmin := false
if adminVal := ctx.Value("is_admin"); adminVal != nil {
if admin, ok := adminVal.(bool); ok {
isAdmin = admin
}
}
if track.UserID != userID && !isAdmin {
return ErrForbidden
}
// Supprimer le fichier physique
if track.FilePath != "" {
if err := os.Remove(track.FilePath); err != nil && !os.IsNotExist(err) {
s.logger.Warn("Failed to delete track file",
zap.Any("track_id", trackID), // Changed to zap.Any for uuid.UUID
zap.String("file_path", track.FilePath),
zap.Error(err),
)
// On continue même si la suppression du fichier échoue
}
}
// Supprimer les fichiers associés (waveform, cover art)
if track.WaveformPath != "" {
if err := os.Remove(track.WaveformPath); err != nil && !os.IsNotExist(err) {
s.logger.Warn("Failed to delete waveform file",
zap.Any("track_id", trackID), // Changed to zap.Any for uuid.UUID
zap.String("waveform_path", track.WaveformPath),
zap.Error(err),
)
}
}
if track.CoverArtPath != "" {
if err := os.Remove(track.CoverArtPath); err != nil && !os.IsNotExist(err) {
s.logger.Warn("Failed to delete cover art file",
zap.Any("track_id", trackID), // Changed to zap.Any for uuid.UUID
zap.String("cover_art_path", track.CoverArtPath),
zap.Error(err),
)
}
}
// Supprimer de la base de données
// GORM gérera automatiquement les relations en cascade grâce aux contraintes OnDelete:CASCADE
if err := s.db.WithContext(ctx).Delete(track).Error; err != nil {
return fmt.Errorf("failed to delete track: %w", err)
}
s.logger.Info("Track deleted",
zap.Any("track_id", trackID), // Changed to zap.Any for uuid.UUID
zap.String("user_id", userID.String()),
zap.String("file_path", track.FilePath),
)
return nil
}
// UpdateStreamStatus updates the stream status and manifest URL of a track
func (s *TrackService) UpdateStreamStatus(ctx context.Context, trackID uuid.UUID, status string, manifestURL string) error { // Changed trackID to uuid.UUID
updates := map[string]interface{}{
"stream_status": status,
}
if manifestURL != "" {
updates["stream_manifest_url"] = manifestURL
}
if status == "ready" {
updates["status"] = models.TrackStatusCompleted
updates["status_message"] = "Ready for streaming"
} else if status == "error" {
updates["status"] = models.TrackStatusFailed
updates["status_message"] = "Transcoding failed"
}
if err := s.db.WithContext(ctx).Model(&models.Track{}).Where("id = ?", trackID).Updates(updates).Error; err != nil {
return fmt.Errorf("failed to update stream status: %w", err)
}
s.logger.Info("Track stream status updated",
zap.Any("track_id", trackID), // Changed to zap.Any for uuid.UUID
zap.String("status", status),
zap.String("manifest_url", manifestURL),
)
return nil
}
// TrackStats représente les statistiques d'un track
type TrackStats struct {
Views int64 `json:"views"`
Likes int64 `json:"likes"`
Comments int64 `json:"comments"`
TotalPlayTime int64 `json:"total_play_time"` // seconds
Downloads int64 `json:"downloads"`
}
// GetTrackStats récupère les statistiques d'un track
func (s *TrackService) GetTrackStats(ctx context.Context, trackID uuid.UUID) (*types.TrackStats, 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 errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTrackNotFound
}
return nil, fmt.Errorf("failed to get track: %w", err)
}
var stats types.TrackStats
// Count likes
if err := s.db.WithContext(ctx).Model(&models.TrackLike{}).
Where("track_id = ?", trackID).
Count(&stats.Likes).Error; err != nil {
return nil, fmt.Errorf("failed to count likes: %w", err)
}
// Count comments (excluding soft-deleted)
if err := s.db.WithContext(ctx).Model(&models.TrackComment{}).
Where("track_id = ?", trackID).
Count(&stats.Comments).Error; err != nil {
return nil, fmt.Errorf("failed to count comments: %w", err)
}
// Count views (total plays) and sum total play time
type PlayStats struct {
Views int64
TotalPlayTime int64
}
var playStats PlayStats
if err := s.db.WithContext(ctx).Model(&models.TrackPlay{}).
Where("track_id = ?", trackID).
Select("COUNT(*) as views, COALESCE(SUM(duration), 0) as total_play_time").
Scan(&playStats).Error; err != nil {
return nil, fmt.Errorf("failed to get play statistics: %w", err)
}
stats.Views = playStats.Views
stats.TotalPlayTime = playStats.TotalPlayTime
// Count downloads (sum of access_count from track_shares where permissions include 'download')
// Note: access_count is incremented when a share link with download permission is accessed
if err := s.db.WithContext(ctx).Model(&models.TrackShare{}).
Where("track_id = ? AND permissions LIKE ?", trackID, "%download%").
Select("COALESCE(SUM(access_count), 0)").
Scan(&stats.Downloads).Error; err != nil {
return nil, fmt.Errorf("failed to count downloads: %w", err)
}
s.logger.Info("Track stats retrieved",
zap.Any("track_id", trackID), // Changed to zap.Any for uuid.UUID
zap.Int64("views", stats.Views),
zap.Int64("likes", stats.Likes),
zap.Int64("comments", stats.Comments),
zap.Int64("total_play_time", stats.TotalPlayTime),
zap.Int64("downloads", stats.Downloads),
)
return &stats, nil
}
// BatchDeleteResult représente le résultat d'une suppression en lot
type BatchDeleteResult struct {
Deleted []uuid.UUID `json:"deleted"` // Changed to uuid.UUID
Failed []BatchDeleteError `json:"failed"`
}
// BatchDeleteError représente une erreur lors de la suppression d'un track
type BatchDeleteError struct {
TrackID uuid.UUID `json:"track_id"` // Changed to uuid.UUID
Error string `json:"error"`
}
// BatchDeleteTracks supprime plusieurs tracks en une seule requête
func (s *TrackService) BatchDeleteTracks(ctx context.Context, trackIDs []uuid.UUID, userID uuid.UUID) (*BatchDeleteResult, error) { // Changed trackIDs to []uuid.UUID
if len(trackIDs) == 0 {
return &BatchDeleteResult{
Deleted: []uuid.UUID{},
Failed: []BatchDeleteError{},
}, nil
}
// Limiter le nombre de tracks à supprimer en une seule fois pour éviter les surcharges
const maxBatchSize = 100
if len(trackIDs) > maxBatchSize {
return nil, fmt.Errorf("batch size exceeds maximum of %d tracks", maxBatchSize)
}
result := &BatchDeleteResult{
Deleted: []uuid.UUID{},
Failed: []BatchDeleteError{},
}
// Récupérer tous les tracks en une seule requête
var tracks []models.Track
if err := s.db.WithContext(ctx).Where("id IN ?", trackIDs).Find(&tracks).Error; err != nil {
return nil, fmt.Errorf("failed to fetch tracks: %w", err)
}
// Créer un map pour un accès rapide
trackMap := make(map[uuid.UUID]*models.Track) // Changed to uuid.UUID
for i := range tracks {
trackMap[tracks[i].ID] = &tracks[i]
}
// MOD-P1-003: Check if user is admin (passed via context value)
isAdmin := false
if adminVal := ctx.Value("is_admin"); adminVal != nil {
if admin, ok := adminVal.(bool); ok {
isAdmin = admin
}
}
// Traiter chaque track
for _, trackID := range trackIDs {
track, exists := trackMap[trackID]
if !exists {
result.Failed = append(result.Failed, BatchDeleteError{
TrackID: trackID,
Error: "track not found",
})
continue
}
// MOD-P1-003: Vérifier l'ownership (admin peut bypass)
if track.UserID != userID && !isAdmin {
result.Failed = append(result.Failed, BatchDeleteError{
TrackID: trackID,
Error: "forbidden: track does not belong to user",
})
continue
}
// Supprimer le track (réutiliser la logique de DeleteTrack)
if err := s.deleteTrackFiles(ctx, track); err != nil {
s.logger.Warn("Failed to delete track files",
zap.Any("track_id", trackID), // Changed to zap.Any for uuid.UUID
zap.Error(err),
)
// On continue même si la suppression des fichiers échoue
}
// Supprimer de la base de données
if err := s.db.WithContext(ctx).Delete(track).Error; err != nil {
result.Failed = append(result.Failed, BatchDeleteError{
TrackID: trackID,
Error: fmt.Sprintf("failed to delete from database: %v", err),
})
continue
}
result.Deleted = append(result.Deleted, trackID)
s.logger.Info("Track deleted in batch",
zap.Any("track_id", trackID), // Changed to zap.Any for uuid.UUID
zap.String("user_id", userID.String()),
)
}
return result, nil
}
// deleteTrackFiles supprime les fichiers physiques d'un track (logique extraite de DeleteTrack)
func (s *TrackService) deleteTrackFiles(ctx context.Context, track *models.Track) error {
var errors []error
// Supprimer le fichier principal
if track.FilePath != "" {
if err := os.Remove(track.FilePath); err != nil && !os.IsNotExist(err) {
errors = append(errors, fmt.Errorf("failed to delete track file %s: %w", track.FilePath, err))
}
}
// Supprimer le fichier waveform
if track.WaveformPath != "" {
if err := os.Remove(track.WaveformPath); err != nil && !os.IsNotExist(err) {
errors = append(errors, fmt.Errorf("failed to delete waveform file %s: %w", track.WaveformPath, err))
}
}
// Supprimer le fichier cover art
if track.CoverArtPath != "" {
if err := os.Remove(track.CoverArtPath); err != nil && !os.IsNotExist(err) {
errors = append(errors, fmt.Errorf("failed to delete cover art file %s: %w", track.CoverArtPath, err))
}
}
// Retourner la première erreur si il y en a, sinon nil
if len(errors) > 0 {
return errors[0]
}
return nil
}
// BatchUpdateResult représente le résultat d'une mise à jour en lot
type BatchUpdateResult struct {
Updated []uuid.UUID `json:"updated"` // Changed to uuid.UUID
Failed []BatchUpdateError `json:"failed"`
}
// BatchUpdateError représente une erreur lors de la mise à jour d'un track
type BatchUpdateError struct {
TrackID uuid.UUID `json:"track_id"` // Changed to uuid.UUID
Error string `json:"error"`
}
// BatchUpdateTracks met à jour plusieurs tracks en une seule requête
func (s *TrackService) BatchUpdateTracks(ctx context.Context, trackIDs []uuid.UUID, userID uuid.UUID, updates map[string]interface{}) (*BatchUpdateResult, error) { // Changed trackIDs to []uuid.UUID
if len(trackIDs) == 0 {
return &BatchUpdateResult{
Updated: []uuid.UUID{},
Failed: []BatchUpdateError{},
}, nil
}
// Limiter le nombre de tracks à mettre à jour en une seule fois
const maxBatchSize = 100
if len(trackIDs) > maxBatchSize {
return nil, fmt.Errorf("batch size exceeds maximum of %d tracks", maxBatchSize)
}
// Valider que les updates ne sont pas vides
if len(updates) == 0 {
return nil, fmt.Errorf("no valid fields to update")
}
// Liste des champs autorisés pour la mise à jour en lot
allowedFields := map[string]bool{
"is_public": true,
"title": true,
"artist": true,
"album": true,
"genre": true,
"year": true,
}
// Filtrer les champs autorisés et valider les valeurs
filteredUpdates := make(map[string]interface{})
for key, value := range updates {
if !allowedFields[key] {
continue // Ignorer les champs non autorisés
}
// Validation spécifique selon le champ
switch key {
case "is_public":
if _, ok := value.(bool); !ok {
return nil, fmt.Errorf("invalid value for is_public: must be boolean")
}
case "title":
if str, ok := value.(string); ok {
if len(str) == 0 {
return nil, fmt.Errorf("title cannot be empty")
}
if len(str) > 255 {
return nil, fmt.Errorf("title exceeds maximum length of 255 characters")
}
} else {
return nil, fmt.Errorf("invalid value for title: must be string")
}
case "artist", "album", "genre":
if str, ok := value.(string); ok {
if key == "genre" && len(str) > 100 {
return nil, fmt.Errorf("genre exceeds maximum length of 100 characters")
}
} else {
return nil, fmt.Errorf("invalid value for %s: must be string", key)
}
case "year":
if num, ok := value.(float64); ok {
year := int(num)
if year < 1900 || year > 2100 {
return nil, fmt.Errorf("year must be between 1900 and 2100")
}
filteredUpdates[key] = year
continue
} else if num, ok := value.(int); ok {
if num < 1900 || num > 2100 {
return nil, fmt.Errorf("year must be between 1900 and 2100")
}
} else {
return nil, fmt.Errorf("invalid value for year: must be integer")
}
}
filteredUpdates[key] = value
}
if len(filteredUpdates) == 0 {
return nil, fmt.Errorf("no valid fields to update")
}
result := &BatchUpdateResult{
Updated: []uuid.UUID{},
Failed: []BatchUpdateError{},
}
// Récupérer tous les tracks en une seule requête
var tracks []models.Track
if err := s.db.WithContext(ctx).Where("id IN ?", trackIDs).Find(&tracks).Error; err != nil {
return nil, fmt.Errorf("failed to fetch tracks: %w", err)
}
// Créer un map pour un accès rapide
trackMap := make(map[uuid.UUID]*models.Track) // Changed to uuid.UUID
for i := range tracks {
trackMap[tracks[i].ID] = &tracks[i]
}
// MOD-P1-003: Check if user is admin (passed via context value)
isAdmin := false
if adminVal := ctx.Value("is_admin"); adminVal != nil {
if admin, ok := adminVal.(bool); ok {
isAdmin = admin
}
}
// Traiter chaque track
for _, trackID := range trackIDs {
track, exists := trackMap[trackID]
if !exists {
result.Failed = append(result.Failed, BatchUpdateError{
TrackID: trackID,
Error: "track not found",
})
continue
}
// MOD-P1-003: Vérifier l'ownership (admin peut bypass)
if track.UserID != userID && !isAdmin {
result.Failed = append(result.Failed, BatchUpdateError{
TrackID: trackID,
Error: "forbidden: track does not belong to user",
})
continue
}
// Appliquer les mises à jour
if err := s.db.WithContext(ctx).Model(track).Updates(filteredUpdates).Error; err != nil {
result.Failed = append(result.Failed, BatchUpdateError{
TrackID: trackID,
Error: fmt.Sprintf("failed to update: %v", err),
})
continue
}
result.Updated = append(result.Updated, trackID)
s.logger.Info("Track updated in batch",
zap.Any("track_id", trackID), // Changed to zap.Any for uuid.UUID
zap.String("user_id", userID.String()),
zap.Any("updates", filteredUpdates),
)
}
return result, nil
}
// UpdateStreamStatus updates the stream status and manifest URL of a track