2025-12-03 19:29:37 +00:00
package track
import (
"context"
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
"strings" // Removed strconv
2025-12-16 16:23:49 +00:00
"time" // MOD-P2-008: Ajouté pour timeout asynchrone
2025-12-03 19:29:37 +00:00
2026-02-14 21:50:23 +00:00
"veza-backend-api/internal/database"
2025-12-13 02:34:34 +00:00
"veza-backend-api/internal/models"
2025-12-16 18:34:08 +00:00
"veza-backend-api/internal/monitoring"
2025-12-24 15:02:16 +00:00
"veza-backend-api/internal/services"
2025-12-13 02:34:34 +00:00
"veza-backend-api/internal/types"
2025-12-03 19:29:37 +00:00
"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
2025-12-24 15:02:16 +00:00
// BE-SVC-001: Add cache service for track metadata
2025-12-03 19:29:37 +00:00
type TrackService struct {
2026-02-14 21:50:23 +00:00
db * gorm . DB // Write operations (and read fallback when readDB is nil)
readDB * gorm . DB // Optional read replica for read-only operations
2026-01-13 18:47:57 +00:00
logger * zap . Logger
uploadDir string
maxFileSize int64
2025-12-24 15:02:16 +00:00
cacheService * services . CacheService
2025-12-03 19:29:37 +00:00
}
2026-02-14 21:50:23 +00:00
// forRead returns the DB to use for read operations (read replica if configured, else primary)
func ( s * TrackService ) forRead ( ) * gorm . DB {
if s . readDB != nil {
return s . readDB
}
return s . db
}
2025-12-03 19:29:37 +00:00
// 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 ,
2026-02-14 21:50:23 +00:00
readDB : nil ,
logger : logger ,
uploadDir : uploadDir ,
maxFileSize : 100 * 1024 * 1024 , // 100MB
}
}
// NewTrackServiceWithDB crée un TrackService avec support read replica (utilise db.ForRead pour les lectures)
func NewTrackServiceWithDB ( db * database . Database , logger * zap . Logger , uploadDir string ) * TrackService {
if uploadDir == "" {
uploadDir = "uploads/tracks"
}
return & TrackService {
db : db . GormDB ,
readDB : db . ForRead ( ) ,
2025-12-03 19:29:37 +00:00
logger : logger ,
uploadDir : uploadDir ,
maxFileSize : 100 * 1024 * 1024 , // 100MB
}
}
2025-12-24 15:02:16 +00:00
// SetCacheService définit le service de cache pour TrackService
// BE-SVC-001: Implement caching layer for frequently accessed data
func ( s * TrackService ) SetCacheService ( cacheService * services . CacheService ) {
s . cacheService = cacheService
}
2025-12-03 19:29:37 +00:00
// 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 {
2025-12-27 00:50:39 +00:00
// FIX #10: Logger l'erreur avec contexte
s . logger . Error ( "Failed to open file for validation" ,
zap . String ( "filename" , fileHeader . Filename ) ,
zap . Int64 ( "size" , fileHeader . Size ) ,
zap . Error ( err ) ,
)
2025-12-03 19:29:37 +00:00
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 {
2025-12-27 00:50:39 +00:00
// FIX #10: Logger l'erreur avec contexte
s . logger . Error ( "Failed to read file header for validation" ,
zap . String ( "filename" , fileHeader . Filename ) ,
zap . Error ( err ) ,
)
2025-12-03 19:29:37 +00:00
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
}
2025-12-21 23:55:51 +00:00
// TrackMetadata contient les métadonnées optionnelles pour un upload
type TrackMetadata struct {
Title string
Artist string
Album string
Genre string
Year int
IsPublic bool
}
2025-12-03 19:29:37 +00:00
// UploadTrack upload un fichier audio et crée un enregistrement Track en base
2025-12-16 16:23:49 +00:00
// 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
2025-12-21 23:55:51 +00:00
func ( s * TrackService ) UploadTrack ( ctx context . Context , userID uuid . UUID , fileHeader * multipart . FileHeader , metadata TrackMetadata ) ( * models . Track , error ) {
2025-12-03 19:29:37 +00:00
// Vérifier le quota utilisateur
if err := s . CheckUserQuota ( ctx , userID , fileHeader . Size ) ; err != nil {
2025-12-27 00:50:39 +00:00
// FIX #10: Logger l'erreur avec contexte
s . logger . Warn ( "User quota check failed" ,
zap . String ( "user_id" , userID . String ( ) ) ,
zap . Int64 ( "file_size" , fileHeader . Size ) ,
zap . String ( "filename" , fileHeader . Filename ) ,
zap . Error ( err ) ,
)
2025-12-03 19:29:37 +00:00
return nil , err
}
// Valider le fichier
if err := s . ValidateTrackFile ( fileHeader ) ; err != nil {
2025-12-27 00:50:39 +00:00
// FIX #10: Logger l'erreur avec contexte
s . logger . Warn ( "Track file validation failed" ,
zap . String ( "user_id" , userID . String ( ) ) ,
zap . String ( "filename" , fileHeader . Filename ) ,
zap . Int64 ( "file_size" , fileHeader . Size ) ,
zap . Error ( err ) ,
)
2025-12-03 19:29:37 +00:00
return nil , err
}
// Créer le répertoire d'upload s'il n'existe pas
2025-12-17 17:20:42 +00:00
fmt . Printf ( "📁 [UPLOAD] Vérification dossier upload: %s\n" , s . uploadDir )
2025-12-03 19:29:37 +00:00
if err := os . MkdirAll ( s . uploadDir , 0755 ) ; err != nil {
2025-12-17 17:20:42 +00:00
fmt . Printf ( "❌ [UPLOAD] Erreur création dossier: %v\n" , err )
2025-12-03 19:29:37 +00:00
return nil , fmt . Errorf ( "%w: failed to create upload directory: %w" , ErrStorageError , err )
}
2025-12-17 17:20:42 +00:00
fmt . Printf ( "✅ [UPLOAD] Dossier upload créé/vérifié: %s\n" , s . uploadDir )
2025-12-03 19:29:37 +00:00
// Générer un nom de fichier unique
timestamp := uuid . New ( )
ext := filepath . Ext ( fileHeader . Filename )
2025-12-21 23:55:51 +00:00
filename := fmt . Sprintf ( "%s_%s%s" , userID . String ( ) , timestamp . String ( ) , ext ) // Fixed format to use strings for UUID
2025-12-03 19:29:37 +00:00
filePath := filepath . Join ( s . uploadDir , filename )
2025-12-17 17:20:42 +00:00
fmt . Printf ( "💾 [UPLOAD] Chemin fichier de destination: %s\n" , filePath )
2025-12-03 19:29:37 +00:00
// Déterminer le format depuis l'extension
format := strings . TrimPrefix ( strings . ToUpper ( ext ) , "." )
if format == "M4A" {
format = "AAC"
}
2025-12-21 23:55:51 +00:00
// Déterminer le titre (métadonnée ou nom de fichier)
title := metadata . Title
if title == "" {
title = strings . TrimSuffix ( fileHeader . Filename , ext )
}
2025-12-03 19:29:37 +00:00
2025-12-16 16:23:49 +00:00
// 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)
2025-12-03 19:29:37 +00:00
track := & models . Track {
UserID : userID ,
2025-12-16 16:23:49 +00:00
FileID : nil , // NULL temporairement - sera mis à jour après création fichier
2025-12-03 19:29:37 +00:00
Title : title ,
2025-12-21 23:55:51 +00:00
Artist : metadata . Artist ,
Album : metadata . Album ,
Genre : metadata . Genre ,
Year : metadata . Year ,
2025-12-03 19:29:37 +00:00
FilePath : filePath ,
FileSize : fileHeader . Size ,
Format : format ,
Duration : 0 , // Sera mis à jour lors du traitement asynchrone
2025-12-21 23:55:51 +00:00
IsPublic : metadata . IsPublic ,
2025-12-03 19:29:37 +00:00
Status : models . TrackStatusUploading ,
StatusMessage : "Upload started" ,
}
if err := s . db . WithContext ( ctx ) . Create ( track ) . Error ; err != nil {
2025-12-17 17:20:42 +00:00
fmt . Printf ( "❌ [UPLOAD] Erreur création enregistrement track: %v\n" , err )
2025-12-03 19:29:37 +00:00
return nil , fmt . Errorf ( "failed to create track record: %w" , err )
}
2025-12-17 17:20:42 +00:00
fmt . Printf ( "✅ [UPLOAD] Enregistrement track créé en DB (ID: %s)\n" , track . ID . String ( ) )
2025-12-03 19:29:37 +00:00
2025-12-16 16:23:49 +00:00
// MOD-P2-008: Lancer la copie fichier en goroutine avec suivi (context + cancellation)
// La goroutine mettra à jour le Status quand terminé
2025-12-17 17:20:42 +00:00
fmt . Printf ( "🚀 [UPLOAD] Lancement copie fichier en asynchrone...\n" )
2025-12-16 16:23:49 +00:00
go s . copyFileAsync ( ctx , track . ID , fileHeader , filePath , userID )
2025-12-16 18:34:08 +00:00
// MOD-P2-003: Enregistrer la métrique business
monitoring . RecordTrackUploaded ( )
2025-12-16 16:23:49 +00:00
s . logger . Info ( "Track upload initiated (async)" ,
2025-12-03 19:29:37 +00:00
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
}
2025-12-16 16:23:49 +00:00
// 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
2025-12-17 17:20:42 +00:00
fmt . Printf ( "📂 [UPLOAD ASYNC] Ouverture fichier source...\n" )
2025-12-16 16:23:49 +00:00
src , err := fileHeader . Open ( )
if err != nil {
2025-12-17 17:20:42 +00:00
fmt . Printf ( "❌ [UPLOAD ASYNC] Erreur ouverture fichier source: %v\n" , err )
2025-12-16 16:23:49 +00:00
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 ( )
2025-12-17 17:20:42 +00:00
fmt . Printf ( "✅ [UPLOAD ASYNC] Fichier source ouvert\n" )
2025-12-16 16:23:49 +00:00
// Créer le fichier de destination
2025-12-17 17:20:42 +00:00
fmt . Printf ( "💾 [UPLOAD ASYNC] Création fichier destination: %s\n" , filePath )
2025-12-16 16:23:49 +00:00
dst , err := os . Create ( filePath )
if err != nil {
2025-12-17 17:20:42 +00:00
fmt . Printf ( "❌ [UPLOAD ASYNC] Erreur création fichier destination: %v\n" , err )
2025-12-16 16:23:49 +00:00
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 ( )
2025-12-17 17:20:42 +00:00
fmt . Printf ( "✅ [UPLOAD ASYNC] Fichier destination créé\n" )
2025-12-16 16:23:49 +00:00
// Copier le fichier avec gestion d'erreurs
2025-12-17 17:20:42 +00:00
fmt . Printf ( "📋 [UPLOAD ASYNC] Début copie fichier...\n" )
2025-12-16 16:23:49 +00:00
bytesWritten , err := io . Copy ( dst , src )
if err != nil {
2025-12-17 17:20:42 +00:00
fmt . Printf ( "❌ [UPLOAD ASYNC] Erreur copie fichier: %v\n" , err )
2025-12-16 16:23:49 +00:00
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
}
2025-12-17 17:20:42 +00:00
fmt . Printf ( "✅ [UPLOAD ASYNC] Fichier copié avec succès (%d bytes écrits)\n" , bytesWritten )
2025-12-16 16:23:49 +00:00
// 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 ) ,
)
}
2025-12-03 19:29:37 +00:00
// 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
2025-12-16 16:23:49 +00:00
// 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 {
2025-12-27 00:50:39 +00:00
// FIX #10: Logger l'erreur avec contexte
s . logger . Error ( "Failed to check track count for quota" ,
zap . String ( "user_id" , userID . String ( ) ) ,
zap . Error ( err ) ,
)
2025-12-03 19:29:37 +00:00
return fmt . Errorf ( "failed to check track count: %w" , err )
}
if trackCount >= MaxTracksPerUser {
2025-12-27 00:50:39 +00:00
s . logger . Warn ( "Track quota exceeded" ,
zap . String ( "user_id" , userID . String ( ) ) ,
zap . Int64 ( "track_count" , trackCount ) ,
zap . Int64 ( "max_tracks" , MaxTracksPerUser ) ,
)
2025-12-03 19:29:37 +00:00
return ErrTrackQuotaExceeded
}
var totalSize int64
if err := s . db . WithContext ( ctx ) . Model ( & models . Track { } ) .
2025-12-16 16:23:49 +00:00
Where ( "creator_id = ?" , userID ) .
2025-12-03 19:29:37 +00:00
Select ( "COALESCE(SUM(file_size), 0)" ) .
Scan ( & totalSize ) . Error ; err != nil {
2025-12-27 00:50:39 +00:00
// FIX #10: Logger l'erreur avec contexte
s . logger . Error ( "Failed to check storage usage for quota" ,
zap . String ( "user_id" , userID . String ( ) ) ,
zap . Error ( err ) ,
)
2025-12-03 19:29:37 +00:00
return fmt . Errorf ( "failed to check storage usage: %w" , err )
}
if totalSize + fileSize > MaxStoragePerUser {
2025-12-27 00:50:39 +00:00
s . logger . Warn ( "Storage quota exceeded" ,
zap . String ( "user_id" , userID . String ( ) ) ,
zap . Int64 ( "total_size" , totalSize ) ,
zap . Int64 ( "file_size" , fileSize ) ,
zap . Int64 ( "max_storage" , MaxStoragePerUser ) ,
)
2025-12-03 19:29:37 +00:00
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
2025-12-16 16:23:49 +00:00
if err := s . db . WithContext ( ctx ) . Model ( & models . Track { } ) . Where ( "creator_id = ?" , userID ) . Count ( & trackCount ) . Error ; err != nil {
2025-12-03 19:29:37 +00:00
return nil , fmt . Errorf ( "failed to get track count: %w" , err )
}
var totalSize int64
if err := s . db . WithContext ( ctx ) . Model ( & models . Track { } ) .
2025-12-16 16:23:49 +00:00
Where ( "creator_id = ?" , userID ) .
2025-12-03 19:29:37 +00:00
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 ) {
2026-02-14 21:50:23 +00:00
// Créer la requête de base avec filtre sur le statut (read replica si configuré)
query := s . forRead ( ) . WithContext ( ctx ) . Model ( & models . Track { } ) . Where ( "status = ?" , models . TrackStatusCompleted )
2025-12-03 19:29:37 +00:00
// Appliquer les filtres
if params . UserID != nil {
2025-12-16 16:23:49 +00:00
query = query . Where ( "creator_id = ?" , * params . UserID )
2025-12-03 19:29:37 +00:00
}
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
2025-12-13 02:34:34 +00:00
if err := query . Preload ( "User" ) . Find ( & tracks ) . Error ; err != nil {
2025-12-03 19:29:37 +00:00
return nil , 0 , fmt . Errorf ( "failed to list tracks: %w" , err )
}
return tracks , total , nil
}
// GetTrackByID récupère un track par son ID
2025-12-13 02:34:34 +00:00
// MOD-P1-003: Preload User pour éviter N+1 queries si User est accédé plus tard
2025-12-24 15:02:16 +00:00
// BE-SVC-001: Add caching for track metadata
2025-12-03 19:29:37 +00:00
func ( s * TrackService ) GetTrackByID ( ctx context . Context , trackID uuid . UUID ) ( * models . Track , error ) { // Changed trackID to uuid.UUID
2025-12-24 15:02:16 +00:00
cacheConfig := services . DefaultCacheConfig ( )
// Try to get from cache first
if s . cacheService != nil {
var cachedTrack models . Track
if err := s . cacheService . GetTrack ( ctx , trackID , & cachedTrack ) ; err == nil {
// Cache hit
return & cachedTrack , nil
}
}
2026-02-14 21:50:23 +00:00
// Cache miss - fetch from database (read replica si configuré)
2025-12-03 19:29:37 +00:00
var track models . Track
2026-02-14 21:50:23 +00:00
if err := s . forRead ( ) . WithContext ( ctx ) .
2025-12-13 02:34:34 +00:00
Preload ( "User" ) .
First ( & track , "id = ?" , trackID ) . Error ; err != nil { // Updated query
2025-12-03 19:29:37 +00:00
if err == gorm . ErrRecordNotFound {
return nil , ErrTrackNotFound
}
return nil , fmt . Errorf ( "failed to get track: %w" , err )
}
2025-12-24 15:02:16 +00:00
// Cache the track
if s . cacheService != nil {
if err := s . cacheService . SetTrack ( ctx , trackID , track , cacheConfig ) ; err != nil {
s . logger . Warn ( "Failed to cache track" , zap . Error ( err ) , zap . String ( "track_id" , trackID . String ( ) ) )
}
}
2025-12-03 19:29:37 +00:00
return & track , nil
}
// UpdateTrackParams représente les paramètres de mise à jour d'un track
type UpdateTrackParams struct {
2026-02-20 14:38:51 +00:00
Title * string ` json:"title" `
Artist * string ` json:"artist" `
Album * string ` json:"album" `
Genre * string ` json:"genre" `
Tags [ ] string ` json:"tags" `
Year * int ` json:"year" `
BPM * int ` json:"bpm" `
MusicalKey * string ` json:"musical_key" `
IsPublic * bool ` json:"is_public" `
2025-12-03 19:29:37 +00:00
}
// UpdateTrack met à jour les métadonnées d'un track
2025-12-24 15:02:16 +00:00
// BE-SVC-001: Invalidate cache on track update
2025-12-03 19:29:37 +00:00
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
}
2025-12-13 02:34:34 +00:00
// 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 {
2025-12-03 19:29:37 +00:00
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
}
2026-02-20 14:38:51 +00:00
if params . Tags != nil {
updates [ "tags" ] = params . Tags
}
2025-12-03 19:29:37 +00:00
if params . Year != nil {
if * params . Year < 0 {
return nil , fmt . Errorf ( "year cannot be negative" )
}
updates [ "year" ] = * params . Year
}
2026-02-20 14:34:00 +00:00
if params . BPM != nil {
if * params . BPM < 0 || * params . BPM > 300 {
return nil , fmt . Errorf ( "bpm must be between 0 and 300" )
}
updates [ "bpm" ] = * params . BPM
}
if params . MusicalKey != nil {
updates [ "musical_key" ] = * params . MusicalKey
}
2025-12-03 19:29:37 +00:00
if params . IsPublic != nil {
updates [ "is_public" ] = * params . IsPublic
}
2025-12-24 15:02:16 +00:00
// Invalidate cache before update
if s . cacheService != nil {
if err := s . cacheService . InvalidateTrackCache ( ctx , trackID ) ; err != nil {
s . logger . Warn ( "Failed to invalidate track cache" , zap . Error ( err ) , zap . String ( "track_id" , trackID . String ( ) ) )
}
}
2025-12-03 19:29:37 +00:00
// 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
}
2025-12-13 02:34:34 +00:00
// 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 {
2025-12-03 19:29:37 +00:00
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
}
2025-12-16 18:34:08 +00:00
switch status {
case "ready" :
2025-12-03 19:29:37 +00:00
updates [ "status" ] = models . TrackStatusCompleted
updates [ "status_message" ] = "Ready for streaming"
2025-12-16 18:34:08 +00:00
case "error" :
2025-12-03 19:29:37 +00:00
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
}
2025-12-06 16:21:59 +00:00
2025-12-03 19:29:37 +00:00
// 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
}
2026-02-20 14:36:28 +00:00
// GetLyrics returns lyrics for a track (E3)
func ( s * TrackService ) GetLyrics ( ctx context . Context , trackID uuid . UUID ) ( * models . TrackLyrics , error ) {
var lyrics models . TrackLyrics
if err := s . forRead ( ) . WithContext ( ctx ) . Where ( "track_id = ?" , trackID ) . First ( & lyrics ) . Error ; err != nil {
if errors . Is ( err , gorm . ErrRecordNotFound ) {
return nil , nil // No lyrics yet
}
return nil , fmt . Errorf ( "failed to get lyrics: %w" , err )
}
return & lyrics , nil
}
// CreateOrUpdateLyrics creates or updates lyrics for a track (E3)
func ( s * TrackService ) CreateOrUpdateLyrics ( ctx context . Context , trackID uuid . UUID , userID uuid . UUID , content string ) ( * models . TrackLyrics , error ) {
// Verify track exists and user owns it
track , err := s . GetTrackByID ( ctx , trackID )
if err != nil {
return nil , err
}
if track . UserID != userID {
return nil , ErrForbidden
}
var lyrics models . TrackLyrics
err = s . db . WithContext ( ctx ) . Where ( "track_id = ?" , trackID ) . First ( & lyrics ) . Error
if err != nil && ! errors . Is ( err , gorm . ErrRecordNotFound ) {
return nil , fmt . Errorf ( "failed to get lyrics: %w" , err )
}
lyrics . TrackID = trackID
lyrics . Content = content
if lyrics . ID == uuid . Nil {
if err := s . db . WithContext ( ctx ) . Create ( & lyrics ) . Error ; err != nil {
return nil , fmt . Errorf ( "failed to create lyrics: %w" , err )
}
} else {
if err := s . db . WithContext ( ctx ) . Save ( & lyrics ) . Error ; err != nil {
return nil , fmt . Errorf ( "failed to update lyrics: %w" , err )
}
}
return & lyrics , nil
}
2025-12-03 19:29:37 +00:00
// BatchDeleteResult représente le résultat d'une suppression en lot
type BatchDeleteResult struct {
2025-12-06 16:21:59 +00:00
Deleted [ ] uuid . UUID ` json:"deleted" ` // Changed to uuid.UUID
2025-12-03 19:29:37 +00:00
Failed [ ] BatchDeleteError ` json:"failed" `
}
// BatchDeleteError représente une erreur lors de la suppression d'un track
type BatchDeleteError struct {
2025-12-06 16:21:59 +00:00
TrackID uuid . UUID ` json:"track_id" ` // Changed to uuid.UUID
Error string ` json:"error" `
2025-12-03 19:29:37 +00:00
}
// 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 ]
}
2025-12-13 02:34:34 +00:00
// 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
}
}
2025-12-03 19:29:37 +00:00
// 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
}
2025-12-13 02:34:34 +00:00
// MOD-P1-003: Vérifier l'ownership (admin peut bypass)
if track . UserID != userID && ! isAdmin {
2025-12-03 19:29:37 +00:00
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 {
2025-12-06 16:21:59 +00:00
Updated [ ] uuid . UUID ` json:"updated" ` // Changed to uuid.UUID
2025-12-03 19:29:37 +00:00
Failed [ ] BatchUpdateError ` json:"failed" `
}
// BatchUpdateError représente une erreur lors de la mise à jour d'un track
type BatchUpdateError struct {
2025-12-06 16:21:59 +00:00
TrackID uuid . UUID ` json:"track_id" ` // Changed to uuid.UUID
Error string ` json:"error" `
2025-12-03 19:29:37 +00:00
}
// 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 ]
}
2025-12-13 02:34:34 +00:00
// 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
}
}
2025-12-03 19:29:37 +00:00
// 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
}
2025-12-13 02:34:34 +00:00
// MOD-P1-003: Vérifier l'ownership (admin peut bypass)
if track . UserID != userID && ! isAdmin {
2025-12-03 19:29:37 +00:00
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