1058 lines
33 KiB
Go
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
|