2025-12-03 19:29:37 +00:00
package track
import (
"context"
2026-03-02 11:35:49 +00:00
"encoding/base64"
2025-12-03 19:29:37 +00:00
"errors"
"fmt"
"io"
"mime/multipart"
"os"
"path/filepath"
2026-03-02 11:35:49 +00:00
"strconv"
"strings"
"time" // MOD-P2-008: Ajouté pour timeout asynchrone
2025-12-03 19:29:37 +00:00
2026-03-09 00:52:56 +00:00
"veza-backend-api/internal/core/discover"
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" )
)
2026-02-22 16:52:39 +00:00
// StreamServiceInterface defines the minimal interface for triggering HLS transcoding on the stream server.
// INT-02: Used to call stream server /internal/jobs/transcode after track upload.
type StreamServiceInterface interface {
StartProcessing ( ctx context . Context , trackID uuid . UUID , filePath string ) error
}
2025-12-03 19:29:37 +00:00
// 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
2026-03-02 18:07:49 +00:00
// v0.943: Batch operations delegated to TrackBatchService
2025-12-03 19:29:37 +00:00
type TrackService struct {
2026-03-02 18:07:49 +00:00
db * gorm . DB // Write operations (and read fallback when readDB is nil)
readDB * gorm . DB // Optional read replica for read-only operations
logger * zap . Logger
uploadDir string
maxFileSize int64
2026-03-09 00:52:56 +00:00
cacheService * services . CacheService
streamService StreamServiceInterface // INT-02: Optional, triggers HLS transcoding after upload
batchService * TrackBatchService // v0.943: batch operations
discoverService * discover . Service // v0.10.1: tags/genres sync
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 {
2026-03-02 18:07:49 +00:00
db : db ,
readDB : nil ,
logger : logger ,
uploadDir : uploadDir ,
maxFileSize : 100 * 1024 * 1024 , // 100MB
batchService : NewTrackBatchService ( db , logger ) ,
2026-02-14 21:50:23 +00:00
}
}
// 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 {
2026-03-02 18:07:49 +00:00
db : db . GormDB ,
readDB : db . ForRead ( ) ,
logger : logger ,
uploadDir : uploadDir ,
maxFileSize : 100 * 1024 * 1024 , // 100MB
batchService : NewTrackBatchService ( db . GormDB , logger ) ,
2025-12-03 19:29:37 +00:00
}
}
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
}
2026-02-22 16:52:39 +00:00
// SetStreamService définit le service de streaming pour déclencher le transcodage HLS après upload.
// INT-02: Enables HLS pipeline - stream server transcodes track after successful upload.
func ( s * TrackService ) SetStreamService ( streamService StreamServiceInterface ) {
s . streamService = streamService
}
2026-03-09 00:52:56 +00:00
// SetDiscoverService définit le service discover pour sync tags/genres (v0.10.1)
func ( s * TrackService ) SetDiscoverService ( d * discover . Service ) {
s . discoverService = d
}
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
2026-02-22 16:44:38 +00:00
s . logger . Debug ( "Checking upload directory" , zap . String ( "upload_dir" , s . uploadDir ) )
2025-12-03 19:29:37 +00:00
if err := os . MkdirAll ( s . uploadDir , 0755 ) ; err != nil {
2026-02-22 16:44:38 +00:00
s . logger . Error ( "Failed to create upload directory" , zap . String ( "upload_dir" , s . uploadDir ) , zap . Error ( err ) )
2025-12-03 19:29:37 +00:00
return nil , fmt . Errorf ( "%w: failed to create upload directory: %w" , ErrStorageError , err )
}
2026-02-22 16:44:38 +00:00
s . logger . Debug ( "Upload directory created/verified" , zap . String ( "upload_dir" , 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 )
2026-02-22 16:44:38 +00:00
s . logger . Debug ( "Upload destination path" , zap . String ( "file_path" , 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 {
2026-02-22 16:44:38 +00:00
s . logger . Error ( "Failed to create track record" , zap . Error ( err ) )
2025-12-03 19:29:37 +00:00
return nil , fmt . Errorf ( "failed to create track record: %w" , err )
}
2026-02-22 16:44:38 +00:00
s . logger . Debug ( "Track record created in DB" , zap . String ( "track_id" , 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é
2026-02-22 16:44:38 +00:00
s . logger . Debug ( "Starting async file copy" )
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
2026-02-22 16:44:38 +00:00
s . logger . Debug ( "Opening source file for async copy" )
2025-12-16 16:23:49 +00:00
src , err := fileHeader . Open ( )
if err != nil {
2026-02-22 16:44:38 +00:00
s . logger . Error ( "Failed to open source file for async copy" , zap . Error ( 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 ( )
2026-02-22 16:44:38 +00:00
s . logger . Debug ( "Source file opened for async copy" )
2025-12-16 16:23:49 +00:00
// Créer le fichier de destination
2026-02-22 16:44:38 +00:00
s . logger . Debug ( "Creating destination file" , zap . String ( "file_path" , filePath ) )
2025-12-16 16:23:49 +00:00
dst , err := os . Create ( filePath )
if err != nil {
2026-02-22 16:44:38 +00:00
s . logger . Error ( "Failed to create destination file" , zap . String ( "file_path" , filePath ) , zap . Error ( 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 ( )
2026-02-22 16:44:38 +00:00
s . logger . Debug ( "Destination file created" )
2025-12-16 16:23:49 +00:00
// Copier le fichier avec gestion d'erreurs
2026-02-22 16:44:38 +00:00
s . logger . Debug ( "Starting file copy" )
2025-12-16 16:23:49 +00:00
bytesWritten , err := io . Copy ( dst , src )
if err != nil {
2026-02-22 16:44:38 +00:00
s . logger . Error ( "File copy failed" , zap . Error ( 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
}
2026-02-22 16:44:38 +00:00
s . logger . Debug ( "File copied successfully" , zap . Int64 ( "bytes_written" , 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..." )
2026-02-22 16:52:39 +00:00
// INT-02: Trigger HLS transcoding on stream server after successful upload
if s . streamService != nil {
if err := s . streamService . StartProcessing ( copyCtx , trackID , filePath ) ; err != nil {
s . logger . Warn ( "Failed to trigger stream server transcoding (track will remain in Processing)" ,
zap . String ( "track_id" , trackID . String ( ) ) ,
zap . String ( "file_path" , filePath ) ,
zap . Error ( err ) ,
)
} else {
s . logger . Info ( "Stream server transcoding triggered" ,
zap . String ( "track_id" , trackID . String ( ) ) ,
zap . String ( "file_path" , filePath ) ,
)
}
}
2025-12-16 16:23:49 +00:00
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
2026-03-02 11:35:49 +00:00
Cursor string // v0.931: opaque cursor for keyset pagination (base64)
2025-12-03 19:29:37 +00:00
UserID * uuid . UUID
Genre * string
Format * string
SortBy string // "created_at", "title", "popularity"
SortOrder string // "asc", "desc"
}
2026-03-02 11:35:49 +00:00
// TrackListResult holds list result with optional next cursor
type TrackListResult struct {
Tracks [ ] * models . Track
Total int64
NextCursor string
}
2025-12-03 19:29:37 +00:00
// 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
}
2026-03-02 11:35:49 +00:00
// ListTracksWithCursor uses keyset pagination on (created_at, id) for consistent performance.
// When params.Cursor is set, decodes it and fetches records after that point.
// Returns NextCursor for the next page when more results exist.
// v0.931: Cursor-based pagination for GET /tracks
func ( s * TrackService ) ListTracksWithCursor ( ctx context . Context , params TrackListParams ) ( * TrackListResult , error ) {
// Cursor-based only supported for sort_by=created_at (default)
if params . SortBy != "created_at" && params . SortBy != "" {
// Fallback to offset-based
tracks , total , err := s . ListTracks ( ctx , params )
if err != nil {
return nil , err
}
return & TrackListResult { Tracks : tracks , Total : total } , nil
}
if params . Limit <= 0 {
params . Limit = 20
}
if params . Limit > 100 {
params . Limit = 100
}
query := s . forRead ( ) . WithContext ( ctx ) . Model ( & models . Track { } ) . Where ( "status = ?" , models . TrackStatusCompleted )
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 )
}
// Decode cursor: base64(created_at_unix_nano|uuid)
var cursorCreatedAt int64
var cursorID uuid . UUID
if params . Cursor != "" {
decoded , err := base64 . RawURLEncoding . DecodeString ( params . Cursor )
if err == nil {
parts := strings . SplitN ( string ( decoded ) , "|" , 2 )
if len ( parts ) == 2 {
if ts , err := strconv . ParseInt ( parts [ 0 ] , 10 , 64 ) ; err == nil {
cursorCreatedAt = ts
}
if uid , err := uuid . Parse ( parts [ 1 ] ) ; err == nil {
cursorID = uid
}
}
}
}
sortOrder := "DESC"
if params . SortOrder == "asc" {
sortOrder = "ASC"
}
if sortOrder == "DESC" {
if params . Cursor != "" && ( cursorCreatedAt != 0 || cursorID != uuid . Nil ) {
query = query . Where ( "(created_at, id) < (?, ?)" , time . Unix ( 0 , cursorCreatedAt ) , cursorID )
}
query = query . Order ( "created_at DESC, id DESC" )
} else {
if params . Cursor != "" && ( cursorCreatedAt != 0 || cursorID != uuid . Nil ) {
query = query . Where ( "(created_at, id) > (?, ?)" , time . Unix ( 0 , cursorCreatedAt ) , cursorID )
}
query = query . Order ( "created_at ASC, id ASC" )
}
// Fetch limit+1 to know if there's a next page
query = query . Limit ( params . Limit + 1 )
var tracks [ ] * models . Track
if err := query . Preload ( "User" ) . Find ( & tracks ) . Error ; err != nil {
return nil , fmt . Errorf ( "failed to list tracks: %w" , err )
}
var nextCursor string
var total int64
if len ( tracks ) > params . Limit {
// Has more - last fetched is the cursor for next page
last := tracks [ params . Limit - 1 ]
nextCursor = base64 . RawURLEncoding . EncodeToString ( [ ] byte (
fmt . Sprintf ( "%d|%s" , last . CreatedAt . UnixNano ( ) , last . ID . String ( ) ) ) )
tracks = tracks [ : params . Limit ]
}
// Total not computed for cursor mode (expensive); use -1 or len as approximation
total = int64 ( len ( tracks ) )
if nextCursor != "" {
total = int64 ( params . Limit ) + 1
}
return & TrackListResult {
Tracks : tracks ,
Total : total ,
NextCursor : nextCursor ,
} , nil
}
2025-12-03 19:29:37 +00:00
// 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
2026-03-09 00:52:56 +00:00
// v0.10.1: Genres for multi-genre (max 3)
2025-12-03 19:29:37 +00:00
type UpdateTrackParams struct {
2026-02-20 14:38:51 +00:00
Title * string ` json:"title" `
Artist * string ` json:"artist" `
Album * string ` json:"album" `
2026-03-09 00:52:56 +00:00
Genre * string ` json:"genre" ` // legacy single
Genres [ ] string ` json:"genres" ` // v0.10.1: max 3 slugs
2026-02-20 14:38:51 +00:00
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
}
2026-03-09 00:52:56 +00:00
// v0.10.1: Tags and Genres via discover service (track_tags, track_genres)
if s . discoverService != nil {
if params . Tags != nil {
if err := s . discoverService . SyncTrackTags ( ctx , trackID , params . Tags ) ; err != nil {
return nil , fmt . Errorf ( "sync tags: %w" , err )
}
}
if params . Genre != nil || len ( params . Genres ) > 0 {
genres := params . Genres
if len ( genres ) == 0 && params . Genre != nil {
genres = [ ] string { * params . Genre }
}
if err := s . discoverService . SyncTrackGenres ( ctx , trackID , genres ) ; err != nil {
return nil , fmt . Errorf ( "sync genres: %w" , err )
}
}
} else {
// Fallback when discover service not configured
if params . Genre != nil {
updates [ "genre" ] = * params . Genre
}
if params . Tags != nil {
updates [ "tags" ] = params . Tags
}
2026-02-20 14:38:51 +00:00
}
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 ( ) ) )
}
}
2026-03-09 00:52:56 +00:00
// v0.10.1: If only tags/genres were updated via discover, reload and return
discoverUpdated := s . discoverService != nil && ( params . Tags != nil || params . Genre != nil || len ( params . Genres ) > 0 )
2025-12-03 19:29:37 +00:00
if len ( updates ) == 0 {
2026-03-09 00:52:56 +00:00
if discoverUpdated {
updatedTrack , err := s . GetTrackByID ( ctx , trackID )
if err != nil {
return nil , err
}
return updatedTrack , nil
}
2025-12-03 19:29:37 +00:00
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
}
2026-03-02 18:07:49 +00:00
// BatchDeleteTracks delegates to TrackBatchService (v0.943)
func ( s * TrackService ) BatchDeleteTracks ( ctx context . Context , trackIDs [ ] uuid . UUID , userID uuid . UUID ) ( * BatchDeleteResult , error ) {
return s . batchService . BatchDeleteTracks ( ctx , trackIDs , userID )
2025-12-03 19:29:37 +00:00
}
// 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
}
2026-03-02 18:07:49 +00:00
// BatchUpdateTracks delegates to TrackBatchService (v0.943)
func ( s * TrackService ) BatchUpdateTracks ( ctx context . Context , trackIDs [ ] uuid . UUID , userID uuid . UUID , updates map [ string ] interface { } ) ( * BatchUpdateResult , error ) {
return s . batchService . BatchUpdateTracks ( ctx , trackIDs , userID , updates )
2025-12-03 19:29:37 +00:00
}
// UpdateStreamStatus updates the stream status and manifest URL of a track