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