package track import ( "context" "errors" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/google/uuid" "veza-backend-api/internal/common" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/handlers" "veza-backend-api/internal/models" "veza-backend-api/internal/response" "veza-backend-api/internal/services" "github.com/gin-gonic/gin" "go.uber.org/zap" // Added zap "gorm.io/gorm" ) // TrackHandler gère les opérations sur les tracks type TrackHandler struct { trackService *TrackService trackUploadService *services.TrackUploadService chunkService *services.TrackChunkService likeService *services.TrackLikeService streamService *services.StreamService searchService *services.TrackSearchService shareService *services.TrackShareService versionService *services.TrackVersionService historyService *services.TrackHistoryService playbackAnalyticsService *services.PlaybackAnalyticsService // BE-API-019: Added for play analytics permissionService *services.PermissionService // MOD-P1-003: Added for admin check uploadValidator *services.UploadValidator // MOD-P1-001: Added for ClamAV scan before persistence } // NewTrackHandler crée un nouveau handler de tracks func NewTrackHandler( trackService *TrackService, trackUploadService *services.TrackUploadService, chunkService *services.TrackChunkService, likeService *services.TrackLikeService, streamService *services.StreamService, ) *TrackHandler { return &TrackHandler{ trackService: trackService, trackUploadService: trackUploadService, chunkService: chunkService, likeService: likeService, streamService: streamService, } } // SetUploadValidator définit le validateur d'upload (pour injection de dépendance) // MOD-P1-001: Added for ClamAV scan before persistence func (h *TrackHandler) SetUploadValidator(uploadValidator *services.UploadValidator) { h.uploadValidator = uploadValidator } // SetPermissionService définit le service de permissions (pour injection de dépendance) // MOD-P1-003: Added for admin check in ownership verification func (h *TrackHandler) SetPermissionService(permissionService *services.PermissionService) { h.permissionService = permissionService } // SetSearchService définit le service de recherche (pour injection de dépendance) func (h *TrackHandler) SetSearchService(searchService *services.TrackSearchService) { h.searchService = searchService } // SetShareService définit le service de partage (pour injection de dépendance) func (h *TrackHandler) SetShareService(shareService *services.TrackShareService) { h.shareService = shareService } // SetVersionService définit le service de versioning (pour injection de dépendance) func (h *TrackHandler) SetVersionService(versionService *services.TrackVersionService) { h.versionService = versionService } // SetHistoryService définit le service d'historique (pour injection de dépendance) func (h *TrackHandler) SetHistoryService(historyService *services.TrackHistoryService) { h.historyService = historyService } // SetPlaybackAnalyticsService définit le service d'analytics de lecture (pour injection de dépendance) // BE-API-019: Implement track play analytics endpoint func (h *TrackHandler) SetPlaybackAnalyticsService(analyticsService *services.PlaybackAnalyticsService) { h.playbackAnalyticsService = analyticsService } // getUserID récupère l'ID utilisateur du contexte de manière sécurisée (fail-secure) // MOD-P1-RES-003: Remplace c.MustGet() pour éviter les panics // Retourne false si user_id est absent ou invalide (répond déjà avec 401) func (h *TrackHandler) getUserID(c *gin.Context) (uuid.UUID, bool) { userIDInterface, exists := c.Get("user_id") if !exists { // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Unauthorized handlers.RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return uuid.Nil, false } userID, ok := userIDInterface.(uuid.UUID) if !ok { // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Unauthorized handlers.RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return uuid.Nil, false } if userID == uuid.Nil { // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Unauthorized handlers.RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized")) return uuid.Nil, false } return userID, true } // respondWithError est un helper pour migrer vers RespondWithAppError // MOD-P1-RES-001: Helper pour standardiser les réponses d'erreur func (h *TrackHandler) respondWithError(c *gin.Context, httpStatus int, message string) { var errCode apperrors.ErrorCode switch httpStatus { case http.StatusBadRequest: errCode = apperrors.ErrCodeValidation case http.StatusUnauthorized: errCode = apperrors.ErrCodeUnauthorized case http.StatusForbidden: errCode = apperrors.ErrCodeForbidden case http.StatusNotFound: errCode = apperrors.ErrCodeNotFound case http.StatusInternalServerError: errCode = apperrors.ErrCodeInternal default: errCode = apperrors.ErrCodeInternal } handlers.RespondWithAppError(c, apperrors.New(errCode, message)) } // UploadTrack gère l'upload d'un fichier audio // @Summary Upload Track // @Description Upload a new track (audio file) // @Tags Track // @Accept multipart/form-data // @Produce json // @Security BearerAuth // @Param file formData file true "Audio File (MP3, WAV, FLAC, OGG)" // @Success 201 {object} response.APIResponse{data=object{track=models.Track}} // @Failure 400 {object} response.APIResponse "No file or validation error" // @Failure 401 {object} response.APIResponse "Unauthorized" // @Failure 403 {object} response.APIResponse "Quota exceeded" // @Failure 500 {object} response.APIResponse "Internal Error" // @Router /tracks [post] func (h *TrackHandler) UploadTrack(c *gin.Context) { // FIX #5: Remplacer fmt.Print* par logs structurés h.trackService.logger.Debug("Upload track request received") // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() userID, ok := h.getUserID(c) if !ok { h.trackService.logger.Warn("Upload track: user not authenticated") return // Erreur déjà envoyée par getUserID } h.trackService.logger.Debug("Upload track: user authenticated", zap.String("user_id", userID.String())) fileHeader, err := c.FormFile("file") if err != nil { h.trackService.logger.Warn("Upload track: failed to get file", zap.Error(err)) // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.BadRequest h.respondWithError(c, http.StatusBadRequest, "no file provided") return } h.trackService.logger.Debug("Upload track: file received", zap.String("filename", fileHeader.Filename), zap.Int64("size", fileHeader.Size), zap.String("user_id", userID.String()), ) // MOD-P1-001: Scanner le fichier avec ClamAV AVANT toute persistance if h.uploadValidator != nil { // MOD-P1-004: Ajouter timeout context pour opération I/O (ClamAV scan) ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer cancel() validationResult, err := h.uploadValidator.ValidateFile(ctx, fileHeader, "audio") if err != nil { // MOD-P1-001: Détecter le type d'erreur ClamAV et retourner code HTTP approprié if strings.Contains(err.Error(), "clamav_unavailable") { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Virus scanning service is temporarily unavailable", "message": "Uploads are disabled for security reasons until the scanning service is restored", "code": "SERVICE_UNAVAILABLE", }) return } if strings.Contains(err.Error(), "clamav_infected") { c.JSON(http.StatusUnprocessableEntity, gin.H{ "error": "File rejected: virus detected", "details": validationResult.Error, "code": "VIRUS_DETECTED", }) return } if strings.Contains(err.Error(), "clamav_scan_error") { c.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Virus scan failed", "message": "Unable to complete virus scan. Upload rejected for security.", "code": "SCAN_ERROR", }) return } // Autre erreur de validation // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Error h.respondWithError(c, http.StatusBadRequest, validationResult.Error) return } if !validationResult.Valid { // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Error h.respondWithError(c, http.StatusBadRequest, validationResult.Error) return } if validationResult.Quarantined { c.JSON(http.StatusUnprocessableEntity, gin.H{ "error": "File rejected: virus detected", "details": validationResult.Error, "code": "VIRUS_DETECTED", }) return } } // Parse metadata yearStr := c.DefaultPostForm("year", "0") year, _ := strconv.Atoi(yearStr) // Ignore error, default 0 is fine isPublicStr := c.DefaultPostForm("is_public", "true") isPublic := isPublicStr == "true" metadata := TrackMetadata{ Title: c.PostForm("title"), Artist: c.PostForm("artist"), Album: c.PostForm("album"), Genre: c.PostForm("genre"), Year: year, IsPublic: isPublic, } // Upload track (validation et quota sont vérifiés dans le service) // MOD-P1-001: Le scan ClamAV a été fait ci-dessus, maintenant on peut persister // MOD-P2-008: UploadTrack crée le Track immédiatement et lance la copie en goroutine // MOD-P1-004: Ajouter timeout context pour opération DB critique (upload track) h.trackService.logger.Debug("Upload track: starting save", zap.String("user_id", userID.String()), zap.String("filename", fileHeader.Filename), zap.Any("metadata", metadata), ) ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) // Upload peut prendre du temps defer cancel() track, err := h.trackService.UploadTrack(ctx, userID, fileHeader, metadata) if err != nil { h.trackService.logger.Error("Upload track: save failed", zap.String("user_id", userID.String()), zap.String("filename", fileHeader.Filename), zap.Error(err), ) // Mapper les erreurs vers des messages utilisateur spécifiques errorMessage := h.mapTrackError(err) statusCode := h.getErrorStatusCode(err) // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Error h.respondWithError(c, statusCode, errorMessage) return } // MOD-P2-008: Sémantique asynchrone - retourner 202 Accepted avec track_id // La copie fichier se fait en arrière-plan, le client peut poller GetUploadStatus c.Header("Location", fmt.Sprintf("/api/v1/tracks/%s/status", track.ID.String())) handlers.RespondSuccess(c, http.StatusAccepted, gin.H{ "track_id": track.ID.String(), "status": string(track.Status), "status_url": fmt.Sprintf("/api/v1/tracks/%s/status", track.ID.String()), "message": "Upload initiated, file is being saved in background", }) // MOD-P2-008: Déclencher le traitement du streaming après la copie (sera fait quand Status=Processing) // On ne peut pas le faire ici car le fichier n'existe pas encore // Ce sera fait dans un job séparé ou via un hook quand Status passe à Processing } // GetUploadStatus récupère le statut d'upload d'un track // @Summary Get Upload Status // @Description Get the processing status of an uploaded track // @Tags Track // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "Track ID" // @Success 200 {object} response.APIResponse{data=object{progress=int}} // @Failure 400 {object} response.APIResponse "Invalid ID" // @Failure 401 {object} response.APIResponse "Unauthorized" // @Failure 404 {object} response.APIResponse "Track not found" // @Router /tracks/{id}/status [get] func (h *TrackHandler) GetUploadStatus(c *gin.Context) { trackIDStr := c.Param("id") if trackIDStr == "" { response.BadRequest(c, "track id is required") return } // MIGRATION UUID: ID est maintenant un UUID // Le trackID doit être un int64 pour le moment car models.Track n'a pas encore migré l'ID? // Attends, j'ai migré models.Track ID vers UUID dans l'étape 1. // Donc trackID est UUID. trackID, err := uuid.Parse(trackIDStr) if err != nil { // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.BadRequest h.respondWithError(c, http.StatusBadRequest, "invalid track id") return } // Vérifier que l'utilisateur est authentifié (userID non utilisé dans cette fonction) // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() if _, ok := h.getUserID(c); !ok { return // Erreur déjà envoyée par getUserID } // Récupérer la progression // TODO(P2-GO-004): trackUploadService attend int64 - Migration UUID partielle à compléter // Je dois mettre à jour trackUploadService. // Pour l'instant, je ne peux pas compiler si je passe UUID. // Je vais supposer que trackUploadService a été migré ou que je dois le faire. // Mais la tâche ne mentionnait pas de migrer trackUploadService. // C'est le problème de dépendance en cascade. // Je vais convertir en int64 si possible pour que ça compile, ou migrer le service. // Mais Track.ID est UUID... // OK, la migration UUID était "complète" pour les modèles principaux. // Mais les services satellites comme TrackUploadService n'ont pas été migrés. // C'est la dette technique identifiée dans le rapport. // Pour que ça compile maintenant, je dois adapter TrackUploadService. // TODO(P2-GO-004): Migration UUID partielle - trackUploadService nécessite migration vers UUID // Ou mieux, je vais mettre à jour TrackUploadService après ce fichier. progress, err := h.trackUploadService.GetUploadProgress(c.Request.Context(), trackID) if err != nil { // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.InternalServerError h.respondWithError(c, http.StatusInternalServerError, "failed to get upload progress") return } // MOD-P1-RES-001: Utiliser RespondSuccess au lieu de response.Success handlers.RespondSuccess(c, http.StatusOK, gin.H{"progress": progress}) } // InitiateChunkedUploadRequest représente la requête pour initialiser un upload par chunks type InitiateChunkedUploadRequest struct { TotalChunks int `json:"total_chunks" binding:"required,min=1" validate:"required,min=1"` TotalSize int64 `json:"total_size" binding:"required,min=1" validate:"required,min=1"` Filename string `json:"filename" binding:"required" validate:"required"` } // InitiateChunkedUpload initialise un nouvel upload par chunks // @Summary Initiate Chunked Upload // @Description Start a new chunked upload session // @Tags Track // @Accept json // @Produce json // @Security BearerAuth // @Param request body InitiateChunkedUploadRequest true "Upload Metadata" // @Success 200 {object} response.APIResponse{data=object{upload_id=string,message=string}} // @Failure 400 {object} response.APIResponse "Validation Error" // @Failure 401 {object} response.APIResponse "Unauthorized" // @Router /tracks/initiate [post] func (h *TrackHandler) InitiateChunkedUpload(c *gin.Context) { // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() userID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } // MOD-P1-002: Utiliser helper centralisé pour bind + validate var req InitiateChunkedUploadRequest if !common.BindAndValidateJSON(c, &req) { return // Erreur déjà envoyée au client } // Initialiser l'upload // InitiateChunkedUpload retourne un string (uploadID) donc pas de souci d'int64 // Note: InitiateChunkedUpload n'accepte pas de context (à migrer si nécessaire) uploadID, err := h.chunkService.InitiateChunkedUpload(userID, req.TotalChunks, req.TotalSize, req.Filename) if err != nil { response.InternalServerError(c, err.Error()) return } response.Success(c, gin.H{ "upload_id": uploadID, "message": "upload initiated successfully", }) } // UploadChunkRequest représente la requête pour uploader un chunk type UploadChunkRequest struct { UploadID string `form:"upload_id" binding:"required"` ChunkNumber int `form:"chunk_number" binding:"required,min=1"` TotalChunks int `form:"total_chunks" binding:"required,min=1"` TotalSize int64 `form:"total_size" binding:"required,min=1"` Filename string `form:"filename" binding:"required"` } // UploadChunk gère l'upload d'un chunk // @Summary Upload Chunk // @Description Upload a single chunk of a file // @Tags Track // @Accept multipart/form-data // @Produce json // @Security BearerAuth // @Param chunk formData file true "Chunk Data" // @Param upload_id formData string true "Upload ID" // @Param chunk_number formData int true "Chunk Number" // @Param total_chunks formData int true "Total Chunks" // @Param total_size formData int64 true "Total Size" // @Param filename formData string true "Filename" // @Success 200 {object} response.APIResponse{data=object{message=string,upload_id=string,received_chunks=int,progress=float64}} // @Failure 400 {object} response.APIResponse "Validation Error" // @Failure 401 {object} response.APIResponse "Unauthorized" // @Router /tracks/chunk [post] func (h *TrackHandler) UploadChunk(c *gin.Context) { // Vérifier que l'utilisateur est authentifié (userID non utilisé dans cette fonction) // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() if _, ok := h.getUserID(c); !ok { return // Erreur déjà envoyée par getUserID } var req UploadChunkRequest if err := c.ShouldBind(&req); err != nil { response.BadRequest(c, err.Error()) return } fileHeader, err := c.FormFile("chunk") if err != nil { response.BadRequest(c, "no chunk file provided") return } // Sauvegarder le chunk if err := h.chunkService.SaveChunk(c.Request.Context(), req.UploadID, req.ChunkNumber, req.TotalChunks, fileHeader); err != nil { response.BadRequest(c, err.Error()) return } // Récupérer la progression receivedChunks, progress, err := h.chunkService.GetUploadProgress(req.UploadID) if err != nil { response.InternalServerError(c, err.Error()) return } response.Success(c, gin.H{ "message": "chunk uploaded successfully", "upload_id": req.UploadID, "received_chunks": receivedChunks, "total_chunks": req.TotalChunks, "progress": progress, }) } // CompleteChunkedUploadRequest représente la requête pour compléter un upload par chunks type CompleteChunkedUploadRequest struct { UploadID string `json:"upload_id" binding:"required" validate:"required,uuid"` } // CompleteChunkedUpload assemble tous les chunks et crée le track final // @Summary Complete Chunked Upload // @Description Finish upload session and assemble file // @Tags Track // @Accept json // @Produce json // @Security BearerAuth // @Param request body CompleteChunkedUploadRequest true "Upload ID" // @Success 201 {object} response.APIResponse{data=object{message=string,track=models.Track,md5=string}} // @Failure 400 {object} response.APIResponse "Validation or Assemblage Error" // @Failure 401 {object} response.APIResponse "Unauthorized" // @Router /tracks/complete [post] func (h *TrackHandler) CompleteChunkedUpload(c *gin.Context) { // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() userID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } // MOD-P1-002: Utiliser helper centralisé pour bind + validate var req CompleteChunkedUploadRequest if !common.BindAndValidateJSON(c, &req) { return // Erreur déjà envoyée au client } // Récupérer les informations de l'upload pour obtenir le filename uploadInfo, err := h.chunkService.GetUploadInfo(req.UploadID) if err != nil { response.BadRequest(c, err.Error()) return } // Générer un nom de fichier unique pour le fichier final timestamp := uuid.New() ext := filepath.Ext(uploadInfo.Filename) if ext == "" { ext = ".mp3" // Par défaut } filename := fmt.Sprintf("%s_%s%s", userID.String(), timestamp.String(), ext) finalPath := filepath.Join("uploads/tracks", userID.String(), filename) // Assurer que le répertoire existe if err := os.MkdirAll(filepath.Dir(finalPath), 0755); err != nil { response.InternalServerError(c, "failed to create directory") return } // Assembler les chunks // MOD-P1-004: Ajouter timeout context pour opération I/O (assemblage chunks) ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) // Assemblage peut prendre du temps defer cancel() finalFilename, totalSize, md5, err := h.chunkService.CompleteChunkedUpload(ctx, req.UploadID, finalPath) if err != nil { errorMessage := h.mapTrackError(err) statusCode := h.getErrorStatusCode(err) response.Error(c, statusCode, errorMessage) return } // Vérifier le quota avant de créer le track final // MOD-P1-004: Ajouter timeout context pour opération DB (quota check) quotaCtx, quotaCancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer quotaCancel() if err := h.trackService.CheckUserQuota(quotaCtx, userID, totalSize); err != nil { errorMessage := h.mapTrackError(err) statusCode := h.getErrorStatusCode(err) // Nettoyer le fichier assemblé os.Remove(finalPath) response.Error(c, statusCode, errorMessage) return } // Déterminer le format ext = filepath.Ext(finalFilename) format := strings.TrimPrefix(strings.ToUpper(ext), ".") if format == "M4A" { format = "AAC" } // Créer le track en base en utilisant CreateTrackFromPath // MOD-P1-004: Ajouter timeout context pour opération DB critique (create track) createCtx, createCancel := context.WithTimeout(c.Request.Context(), 10*time.Second) defer createCancel() track, err := h.trackService.CreateTrackFromPath(createCtx, userID, finalPath, finalFilename, totalSize, format) if err != nil { // Nettoyer le fichier en cas d'erreur os.Remove(finalPath) errorMessage := h.mapTrackError(err) statusCode := h.getErrorStatusCode(err) response.Error(c, statusCode, errorMessage) return } // Mettre à jour le message de statut avec le MD5 if err := h.trackUploadService.UpdateUploadStatus(c.Request.Context(), track.ID, models.TrackStatusUploading, fmt.Sprintf("Upload completed, MD5: %s", md5)); err != nil { // Log l'erreur mais ne pas faire échouer la requête h.trackService.logger.Error("Failed to update track upload status after completion", zap.Error(err), zap.Any("track_id", track.ID)) } // Déclencher le traitement du streaming if h.streamService != nil { // FIX #23: Enrichir le contexte avec le request_id pour propagation ctx := c.Request.Context() if requestID := c.GetString("request_id"); requestID != "" { ctx = context.WithValue(ctx, "request_id", requestID) } if err := h.streamService.StartProcessing(ctx, track.ID, track.FilePath); err != nil { // FIX #10: Logger l'erreur avec contexte h.trackService.logger.Error("Failed to start stream processing", zap.String("track_id", track.ID.String()), zap.String("file_path", track.FilePath), zap.Error(err), ) } else { // h.trackUploadService.UpdateUploadStatus(c.Request.Context(), track.ID, models.TrackStatusProcessing, "Processing audio...") } } response.Created(c, gin.H{ "message": "upload completed successfully", "track": track, "md5": md5, }) } // mapTrackError mappe les erreurs techniques vers des messages utilisateur func (h *TrackHandler) mapTrackError(err error) string { if err == nil { return "unknown error" } errStr := err.Error() // Erreurs de validation if strings.Contains(errStr, "invalid track format") || strings.Contains(errStr, "invalid file format") { return "Invalid file format. Allowed formats: MP3, FLAC, WAV, OGG" } if strings.Contains(errStr, "file size exceeds") || strings.Contains(errStr, "too large") { return "File size exceeds maximum allowed size of 100MB" } if strings.Contains(errStr, "file is empty") { return "The uploaded file is empty" } // Erreurs de quota if strings.Contains(errStr, "track quota exceeded") { return "You have reached the maximum number of tracks allowed" } if strings.Contains(errStr, "storage quota exceeded") { return "You have reached your storage quota. Please delete some tracks to free up space" } // Erreurs réseau if strings.Contains(errStr, "network error") || strings.Contains(errStr, "timeout") || strings.Contains(errStr, "connection") { return "Network error occurred. Please try again" } // Erreurs de stockage if strings.Contains(errStr, "storage error") || strings.Contains(errStr, "failed to save file") { return "Failed to save file. Please try again" } if strings.Contains(errStr, "failed to create upload directory") { return "Failed to prepare storage. Please try again later" } // Erreur par défaut return "An error occurred during upload. Please try again" } // getErrorStatusCode retourne le code de statut HTTP approprié pour une erreur func (h *TrackHandler) getErrorStatusCode(err error) int { if err == nil { return http.StatusInternalServerError } errStr := err.Error() // Erreurs de validation -> 400 if strings.Contains(errStr, "invalid") || strings.Contains(errStr, "too large") || strings.Contains(errStr, "empty") { return http.StatusBadRequest } // Erreurs de quota -> 403 if strings.Contains(errStr, "quota exceeded") { return http.StatusForbidden } // Erreurs réseau -> 503 (Service Unavailable) if strings.Contains(errStr, "network error") || strings.Contains(errStr, "timeout") || strings.Contains(errStr, "connection") { return http.StatusServiceUnavailable } // Erreurs de stockage -> 500 if strings.Contains(errStr, "storage error") || strings.Contains(errStr, "failed to save") { return http.StatusInternalServerError } // Par défaut return http.StatusInternalServerError } // GetUploadQuota récupère les informations de quota d'upload pour un utilisateur // @Summary Get Upload Quota // @Description Get remaining upload quota for the user // @Tags Track // @Accept json // @Produce json // @Security BearerAuth // @Param id path string false "User ID (optional, defaults to current user)" // @Success 200 {object} response.APIResponse{data=object{quota=object}} // @Failure 401 {object} response.APIResponse "Unauthorized" // @Failure 403 {object} response.APIResponse "Forbidden" // @Router /tracks/quota/{id} [get] func (h *TrackHandler) GetUploadQuota(c *gin.Context) { // Récupérer l'ID utilisateur depuis l'URL ou depuis le contexte d'authentification userIDParam := c.Param("id") var userID uuid.UUID var err error if userIDParam == "" || userIDParam == "me" { // Si "me" ou vide, utiliser l'utilisateur authentifié // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() var ok bool userID, ok = h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } } else { // Parse UUID userID, err = uuid.Parse(userIDParam) if err != nil { response.BadRequest(c, "invalid user id") return } } // Vérifier que l'utilisateur peut accéder à ces informations (soit lui-même, soit admin) // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() authenticatedUserID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } // Un utilisateur ne peut voir que son propre quota (sauf admin, mais on simplifie pour l'instant) if authenticatedUserID != userID { response.Forbidden(c, "forbidden: you can only view your own quota") return } // Récupérer le quota quota, err := h.trackService.GetUserQuota(c.Request.Context(), userID) if err != nil { response.InternalServerError(c, "failed to get quota") return } response.Success(c, gin.H{ "quota": quota, }) } // ResumeUpload récupère l'état d'un upload pour permettre la reprise // @Summary Resume Upload // @Description Get state of an interrupted upload // @Tags Track // @Accept json // @Produce json // @Security BearerAuth // @Param uploadId path string true "Upload ID" // @Success 200 {object} response.APIResponse{data=object{upload_id=string,chunks_received=int}} // @Failure 404 {object} response.APIResponse "Upload session not found" // @Router /tracks/resume/{uploadId} [get] func (h *TrackHandler) ResumeUpload(c *gin.Context) { // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() userID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } uploadID := c.Param("uploadId") if uploadID == "" { response.BadRequest(c, "upload_id is required") return } // Récupérer l'état de l'upload state, err := h.chunkService.GetUploadState(uploadID) if err != nil { response.NotFound(c, "upload not found") return } // Vérifier que l'upload appartient à l'utilisateur authentifié if state.UserID != userID { response.Forbidden(c, "forbidden: you can only resume your own uploads") return } response.Success(c, gin.H{ "upload_id": state.UploadID, "user_id": state.UserID, "total_chunks": state.TotalChunks, "total_size": state.TotalSize, "filename": state.Filename, "chunks_received": state.ChunksReceived, "received_count": state.ReceivedCount, "last_chunk": state.LastChunk, "progress": state.Progress, "created_at": state.CreatedAt, "updated_at": state.UpdatedAt, }) } // ListTracks gère la liste des tracks avec pagination, filtres et tri // @Summary List Tracks // @Description Get a paginated list of tracks with filters // @Tags Track // @Accept json // @Produce json // @Param page query int false "Page number" default(1) // @Param limit query int false "Items per page" default(20) // @Param user_id query string false "Filter by User ID" // @Param genre query string false "Filter by Genre" // @Param format query string false "Filter by Format" // @Param sort_by query string false "Sort field" default(created_at) // @Param sort_order query string false "Sort order (asc/desc)" default(desc) // @Success 200 {object} response.APIResponse{data=object{tracks=[]models.Track,pagination=object}} // @Failure 500 {object} response.APIResponse "Internal Error" // @Router /tracks [get] func (h *TrackHandler) ListTracks(c *gin.Context) { // Récupérer les paramètres de query page := c.DefaultQuery("page", "1") limit := c.DefaultQuery("limit", "20") userIDStr := c.Query("user_id") genre := c.Query("genre") format := c.Query("format") sortBy := c.DefaultQuery("sort_by", "created_at") sortOrder := c.DefaultQuery("sort_order", "desc") // Parser les paramètres var pageInt, limitInt int if _, err := fmt.Sscanf(page, "%d", &pageInt); err != nil || pageInt < 1 { pageInt = 1 } if _, err := fmt.Sscanf(limit, "%d", &limitInt); err != nil || limitInt < 1 { limitInt = 20 } // Construire les paramètres params := TrackListParams{ Page: pageInt, Limit: limitInt, SortBy: sortBy, SortOrder: sortOrder, } // Parser user_id si fourni if userIDStr != "" { if uid, err := uuid.Parse(userIDStr); err == nil { params.UserID = &uid } } // Parser genre si fourni if genre != "" { params.Genre = &genre } // Parser format si fourni if format != "" { params.Format = &format } // Appeler le service tracks, total, err := h.trackService.ListTracks(c.Request.Context(), params) if err != nil { response.InternalServerError(c, "failed to list tracks") return } // INT-007: Standardize pagination format pagination := handlers.BuildPaginationData(pageInt, limitInt, total) // Masquer l'URL de stream pour les utilisateurs non authentifiés _, exists := c.Get("user_id") if !exists { for _, t := range tracks { t.StreamManifestURL = "" } } response.Success(c, gin.H{ "tracks": tracks, "pagination": pagination, }) } // GetTrack gère la récupération d'un track par son ID // @Summary Get Track by ID // @Description Get detailed information about a track // @Tags Track // @Accept json // @Produce json // @Param id path string true "Track ID" // @Success 200 {object} response.APIResponse{data=object{track=models.Track}} // @Failure 400 {object} response.APIResponse "Invalid ID" // @Failure 404 {object} response.APIResponse "Track not found" // @Router /tracks/{id} [get] func (h *TrackHandler) GetTrack(c *gin.Context) { trackIDStr := c.Param("id") if trackIDStr == "" { response.BadRequest(c, "track id is required") return } // MIGRATION UUID: TrackID is UUID trackID, err := uuid.Parse(trackIDStr) if err != nil { response.BadRequest(c, "invalid track id") return } track, err := h.trackService.GetTrackByID(c.Request.Context(), trackID) if err != nil { if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { response.NotFound(c, "track not found") return } response.InternalServerError(c, "failed to get track") return } // Masquer l'URL de stream pour les utilisateurs non authentifiés _, exists := c.Get("user_id") if !exists { track.StreamManifestURL = "" } response.Success(c, gin.H{"track": track}) } // UpdateTrackRequest représente la requête de mise à jour d'un track // MOD-P1-002: Added validation tags for systematic input validation type UpdateTrackRequest struct { Title *string `json:"title" binding:"omitempty,min=1,max=255" validate:"omitempty,min=1,max=255"` Artist *string `json:"artist" binding:"omitempty,max=255" validate:"omitempty,max=255"` Album *string `json:"album" binding:"omitempty,max=255" validate:"omitempty,max=255"` Genre *string `json:"genre" binding:"omitempty,max=100" validate:"omitempty,max=100"` Year *int `json:"year" binding:"omitempty,min=1900,max=2100" validate:"omitempty,min=1900,max=2100"` IsPublic *bool `json:"is_public"` } // UpdateTrack gère la mise à jour d'un track // @Summary Update Track // @Description Update track metadata // @Tags Track // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "Track ID" // @Param track body UpdateTrackRequest true "Track Metadata" // @Success 200 {object} response.APIResponse{data=object{track=models.Track}} // @Failure 400 {object} response.APIResponse "Validation Error" // @Failure 401 {object} response.APIResponse "Unauthorized" // @Failure 403 {object} response.APIResponse "Forbidden" // @Failure 404 {object} response.APIResponse "Track not found" // @Router /tracks/{id} [put] func (h *TrackHandler) UpdateTrack(c *gin.Context) { // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() userID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } trackIDStr := c.Param("id") if trackIDStr == "" { response.BadRequest(c, "track id is required") return } // MIGRATION UUID: TrackID is UUID trackID, err := uuid.Parse(trackIDStr) if err != nil { response.BadRequest(c, "invalid track id") return } // MOD-P1-002: Utiliser helper centralisé pour bind + validate var req UpdateTrackRequest if !common.BindAndValidateJSON(c, &req) { return // Erreur déjà envoyée au client } // Convertir la requête en paramètres de service params := UpdateTrackParams{ Title: req.Title, Artist: req.Artist, Album: req.Album, Genre: req.Genre, Year: req.Year, IsPublic: req.IsPublic, } // MOD-P1-003: Check if user is admin for ownership bypass isAdmin := false if h.permissionService != nil { hasRole, err := h.permissionService.HasRole(c.Request.Context(), userID, "admin") if err == nil && hasRole { isAdmin = true } } // Pass isAdmin via context ctx := context.WithValue(c.Request.Context(), "is_admin", isAdmin) track, err := h.trackService.UpdateTrack(ctx, trackID, userID, params) if err != nil { if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { response.NotFound(c, "track not found") return } if errors.Is(err, ErrForbidden) { response.Forbidden(c, "forbidden") return } // Erreur de validation (title empty, year negative, etc.) if strings.Contains(err.Error(), "cannot be") { // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.BadRequest h.respondWithError(c, http.StatusBadRequest, err.Error()) return } // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.InternalServerError h.respondWithError(c, http.StatusInternalServerError, "failed to update track") return } // MOD-P1-RES-001: Utiliser RespondSuccess au lieu de response.Success handlers.RespondSuccess(c, http.StatusOK, gin.H{"track": track}) } // DeleteTrack gère la suppression d'un track // @Summary Delete Track // @Description Permanently delete a track // @Tags Track // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "Track ID" // @Success 200 {object} response.APIResponse{data=object{message=string}} // @Failure 401 {object} response.APIResponse "Unauthorized" // @Failure 403 {object} response.APIResponse "Forbidden" // @Failure 404 {object} response.APIResponse "Track not found" // @Router /tracks/{id} [delete] func (h *TrackHandler) DeleteTrack(c *gin.Context) { // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() userID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } trackIDStr := c.Param("id") if trackIDStr == "" { // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de c.JSON h.respondWithError(c, http.StatusBadRequest, "track id is required") return } // MIGRATION UUID: TrackID is UUID trackID, err := uuid.Parse(trackIDStr) if err != nil { // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.BadRequest h.respondWithError(c, http.StatusBadRequest, "invalid track id") return } // MOD-P1-003: Check if user is admin for ownership bypass isAdmin := false if h.permissionService != nil { hasRole, err := h.permissionService.HasRole(c.Request.Context(), userID, "admin") if err == nil && hasRole { isAdmin = true } } // Pass isAdmin via context ctx := context.WithValue(c.Request.Context(), "is_admin", isAdmin) err = h.trackService.DeleteTrack(ctx, trackID, userID) if err != nil { if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.NotFound h.respondWithError(c, http.StatusNotFound, "track not found") return } if errors.Is(err, ErrForbidden) { // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Forbidden h.respondWithError(c, http.StatusForbidden, "forbidden") return } // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.InternalServerError h.respondWithError(c, http.StatusInternalServerError, "failed to delete track") return } // MOD-P1-RES-001: Utiliser RespondSuccess au lieu de response.Success handlers.RespondSuccess(c, http.StatusOK, gin.H{"message": "track deleted successfully"}) } // BatchDeleteRequest représente la requête pour supprimer plusieurs tracks type BatchDeleteRequest struct { TrackIDs []string `json:"track_ids" binding:"required" validate:"required,min=1,dive,uuid"` } // BatchDeleteTracks gère la suppression en lot de plusieurs tracks // POST /api/v1/tracks/batch/delete // BE-API-024: Implement track batch operations validation // @Summary Batch Delete Tracks // @Description Delete multiple tracks at once // @Tags Track // @Accept json // @Produce json // @Security BearerAuth // @Param request body BatchDeleteRequest true "List of Track IDs" // @Success 200 {object} response.APIResponse{data=object{deleted=[]string,failed=object}} // @Failure 400 {object} response.APIResponse "Validation Error" // @Failure 500 {object} response.APIResponse "Internal Error" // @Router /tracks/batch/delete [post] func (h *TrackHandler) BatchDeleteTracks(c *gin.Context) { // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() userID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } // MOD-P1-002: Utiliser helper centralisé pour bind + validate var req BatchDeleteRequest if !common.BindAndValidateJSON(c, &req) { return // Erreur déjà envoyée au client } // Convertir les IDs en UUIDs var trackUUIDs []uuid.UUID for _, idStr := range req.TrackIDs { if uid, err := uuid.Parse(idStr); err == nil { trackUUIDs = append(trackUUIDs, uid) } } // MOD-P1-003: Check if user is admin for ownership bypass isAdmin := false if h.permissionService != nil { hasRole, err := h.permissionService.HasRole(c.Request.Context(), userID, "admin") if err == nil && hasRole { isAdmin = true } } // Pass isAdmin via context ctx := context.WithValue(c.Request.Context(), "is_admin", isAdmin) result, err := h.trackService.BatchDeleteTracks(ctx, trackUUIDs, userID) if err != nil { // Vérifier si c'est une erreur de taille de batch if strings.Contains(err.Error(), "batch size exceeds maximum") { handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error())) return } handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to delete tracks", err)) return } // BE-API-024: Standardize batch delete response format handlers.RespondSuccess(c, http.StatusOK, gin.H{ "deleted": result.Deleted, "failed": result.Failed, }) } // BatchUpdateRequest représente la requête pour mettre à jour plusieurs tracks type BatchUpdateRequest struct { TrackIDs []string `json:"track_ids" binding:"required" validate:"required,min=1,dive,uuid"` Updates map[string]interface{} `json:"updates" binding:"required" validate:"required,min=1"` } // BatchUpdateTracks gère la mise à jour en lot de plusieurs tracks // POST /api/v1/tracks/batch/update // BE-API-024: Implement track batch operations validation func (h *TrackHandler) BatchUpdateTracks(c *gin.Context) { // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() userID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } // MOD-P1-002: Utiliser helper centralisé pour bind + validate var req BatchUpdateRequest if !common.BindAndValidateJSON(c, &req) { return // Erreur déjà envoyée au client } // Convertir les IDs en UUIDs var trackUUIDs []uuid.UUID for _, idStr := range req.TrackIDs { if uid, err := uuid.Parse(idStr); err == nil { trackUUIDs = append(trackUUIDs, uid) } } // MOD-P1-003: Check if user is admin for ownership bypass isAdmin := false if h.permissionService != nil { hasRole, err := h.permissionService.HasRole(c.Request.Context(), userID, "admin") if err == nil && hasRole { isAdmin = true } } // Pass isAdmin via context ctx := context.WithValue(c.Request.Context(), "is_admin", isAdmin) result, err := h.trackService.BatchUpdateTracks(ctx, trackUUIDs, userID, req.Updates) if err != nil { // Vérifier si c'est une erreur de validation if strings.Contains(err.Error(), "batch size exceeds maximum") || strings.Contains(err.Error(), "cannot be empty") || strings.Contains(err.Error(), "invalid value") || strings.Contains(err.Error(), "exceeds maximum length") || strings.Contains(err.Error(), "must be between") { // BE-API-024: Standardize batch update error response format handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error())) return } // BE-API-024: Standardize batch update error response format handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to update tracks", err)) return } // BE-API-024: Standardize batch update response format handlers.RespondSuccess(c, http.StatusOK, gin.H{ "updated": result.Updated, "failed": result.Failed, }) } // LikeTrack gère l'ajout d'un like sur un track func (h *TrackHandler) LikeTrack(c *gin.Context) { // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() userID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } trackIDStr := c.Param("id") if trackIDStr == "" { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusBadRequest, "track id is required") return } // MIGRATION UUID: TrackID is UUID trackID, err := uuid.Parse(trackIDStr) if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusBadRequest, "invalid track id") return } if err := h.likeService.LikeTrack(c.Request.Context(), userID, trackID); err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H if err.Error() == "track not found" { h.respondWithError(c, http.StatusNotFound, "track not found") return } h.respondWithError(c, http.StatusInternalServerError, err.Error()) return } c.JSON(http.StatusOK, gin.H{"message": "track liked"}) } // UnlikeTrack gère la suppression d'un like sur un track func (h *TrackHandler) UnlikeTrack(c *gin.Context) { // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() userID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } trackIDStr := c.Param("id") if trackIDStr == "" { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusBadRequest, "track id is required") return } // MIGRATION UUID: TrackID is UUID trackID, err := uuid.Parse(trackIDStr) if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusBadRequest, "invalid track id") return } if err := h.likeService.UnlikeTrack(c.Request.Context(), userID, trackID); err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusInternalServerError, err.Error()) return } c.JSON(http.StatusOK, gin.H{"message": "track unliked"}) } // GetTrackLikes gère la récupération du nombre de likes d'un track func (h *TrackHandler) GetTrackLikes(c *gin.Context) { trackIDStr := c.Param("id") if trackIDStr == "" { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusBadRequest, "track id is required") return } // MIGRATION UUID: TrackID is UUID trackID, err := uuid.Parse(trackIDStr) if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusBadRequest, "invalid track id") return } count, err := h.likeService.GetTrackLikesCount(c.Request.Context(), trackID) if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusInternalServerError, err.Error()) return } // Vérifier si l'utilisateur a liké ce track (optionnel) var isLiked bool if userIDInterface, exists := c.Get("user_id"); exists { userID, ok := userIDInterface.(uuid.UUID) if ok && userID != uuid.Nil { isLiked, _ = h.likeService.IsLiked(c.Request.Context(), userID, trackID) } } c.JSON(http.StatusOK, gin.H{ "count": count, "is_liked": isLiked, }) } // GetUserLikedTracks gère la récupération des tracks likés par un utilisateur // GET /api/v1/users/:id/likes // BE-API-027: Implement user liked tracks endpoint func (h *TrackHandler) GetUserLikedTracks(c *gin.Context) { userIDStr := c.Param("id") if userIDStr == "" { handlers.RespondWithAppError(c, apperrors.NewValidationError("user id is required")) return } userID, err := uuid.Parse(userIDStr) if err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid user id")) return } // Parse pagination parameters limit := 20 // default if limitStr := c.Query("limit"); limitStr != "" { if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { // Limiter à un maximum raisonnable if parsedLimit > 100 { parsedLimit = 100 } limit = parsedLimit } } offset := 0 // default if offsetStr := c.Query("offset"); offsetStr != "" { if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { offset = parsedOffset } } tracks, err := h.likeService.GetUserLikedTracks(c.Request.Context(), userID, limit, offset) if err != nil { handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get user liked tracks", err)) return } total, err := h.likeService.GetUserLikedTracksCount(c.Request.Context(), userID) if err != nil { handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get user liked tracks count", err)) return } // BE-API-027: Standardize response format handlers.RespondSuccess(c, http.StatusOK, gin.H{ "tracks": tracks, "total": total, "limit": limit, "offset": offset, }) } // SearchTracks gère la recherche avancée de tracks func (h *TrackHandler) SearchTracks(c *gin.Context) { if h.searchService == nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusInternalServerError, "search service not available") return } // Récupérer les paramètres de query params := services.TrackSearchParams{ Query: c.Query("q"), TagMode: c.DefaultQuery("tag_mode", "OR"), Page: 1, Limit: 20, SortBy: c.DefaultQuery("sort_by", "created_at"), SortOrder: c.DefaultQuery("sort_order", "desc"), } // Parser page if pageStr := c.Query("page"); pageStr != "" { if page, err := strconv.Atoi(pageStr); err == nil && page > 0 { params.Page = page } } // Parser limit if limitStr := c.Query("limit"); limitStr != "" { if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 { params.Limit = limit } } // Parser tags if tagsStr := c.Query("tags"); tagsStr != "" { params.Tags = strings.Split(tagsStr, ",") for i := range params.Tags { params.Tags[i] = strings.TrimSpace(params.Tags[i]) } } // Parser min_duration if minDurationStr := c.Query("min_duration"); minDurationStr != "" { if minDuration, err := strconv.Atoi(minDurationStr); err == nil && minDuration >= 0 { params.MinDuration = &minDuration } } // Parser max_duration if maxDurationStr := c.Query("max_duration"); maxDurationStr != "" { if maxDuration, err := strconv.Atoi(maxDurationStr); err == nil && maxDuration >= 0 { params.MaxDuration = &maxDuration } } // Parser min_bpm if minBPMStr := c.Query("min_bpm"); minBPMStr != "" { if minBPM, err := strconv.Atoi(minBPMStr); err == nil && minBPM >= 0 { params.MinBPM = &minBPM } } // Parser max_bpm if maxBPMStr := c.Query("max_bpm"); maxBPMStr != "" { if maxBPM, err := strconv.Atoi(maxBPMStr); err == nil && maxBPM >= 0 { params.MaxBPM = &maxBPM } } // Parser genre if genre := c.Query("genre"); genre != "" { params.Genre = &genre } // Parser format if format := c.Query("format"); format != "" { params.Format = &format } // Parser min_date if minDate := c.Query("min_date"); minDate != "" { params.MinDate = &minDate } // Parser max_date if maxDate := c.Query("max_date"); maxDate != "" { params.MaxDate = &maxDate } // Effectuer la recherche avec filtres combinés tracks, total, err := h.searchService.SearchTracks(c.Request.Context(), params) if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusInternalServerError, "failed to search tracks") return } // Calculer les métadonnées de pagination totalPages := (int(total) + params.Limit - 1) / params.Limit if totalPages == 0 { totalPages = 1 } c.JSON(http.StatusOK, gin.H{ "tracks": tracks, "pagination": gin.H{ "page": params.Page, "limit": params.Limit, "total": total, "total_pages": totalPages, }, }) } // DownloadTrack gère le téléchargement d'un track func (h *TrackHandler) DownloadTrack(c *gin.Context) { // Récupérer l'utilisateur s'il est authentifié var userID uuid.UUID if userIDInterface, exists := c.Get("user_id"); exists { if uid, ok := userIDInterface.(uuid.UUID); ok { userID = uid } } trackIDStr := c.Param("id") if trackIDStr == "" { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusBadRequest, "track id is required") return } // MIGRATION UUID: TrackID is UUID trackID, err := uuid.Parse(trackIDStr) if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusBadRequest, "invalid track id") return } // Récupérer le track track, err := h.trackService.GetTrackByID(c.Request.Context(), trackID) if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { h.respondWithError(c, http.StatusNotFound, "track not found") return } h.respondWithError(c, http.StatusInternalServerError, "failed to get track") return } // Vérifier les permissions via share token si présent if shareToken := c.Query("share_token"); shareToken != "" { if h.shareService == nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusInternalServerError, "share service not available") return } share, err := h.shareService.ValidateShareToken(c.Request.Context(), shareToken) if err != nil { if errors.Is(err, services.ErrShareNotFound) { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusForbidden, "invalid share token") return } if errors.Is(err, services.ErrShareExpired) { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusForbidden, "share link expired") return } // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusInternalServerError, "failed to validate share token") return } // Vérifier que le share correspond au track if share.TrackID != trackID { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusForbidden, "invalid share token") return } // Vérifier la permission download if !h.shareService.CheckPermission(share, "download") { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusForbidden, "download not allowed") return } } else { // Vérifier les permissions normales (public ou owner) if !track.IsPublic && track.UserID != userID { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusForbidden, "forbidden") return } } // Vérifier que le fichier existe if _, err := os.Stat(track.FilePath); os.IsNotExist(err) { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusNotFound, "track file not found") return } // Servir le fichier avec les headers appropriés c.Header("Content-Type", getContentType(track.Format)) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", track.Title)) c.File(track.FilePath) } // CreateShareRequest représente la requête pour créer un lien de partage type CreateShareRequest struct { Permissions string `json:"permissions" binding:"required" validate:"required,oneof=read write admin"` ExpiresAt *time.Time `json:"expires_at,omitempty"` } // CreateShare crée un nouveau lien de partage pour un track func (h *TrackHandler) CreateShare(c *gin.Context) { // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() userID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } trackIDStr := c.Param("id") if trackIDStr == "" { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusBadRequest, "track id is required") return } // MIGRATION UUID: TrackID is UUID trackID, err := uuid.Parse(trackIDStr) if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusBadRequest, "invalid track id") return } if h.shareService == nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusInternalServerError, "share service not available") return } // MOD-P1-002: Utiliser helper centralisé pour bind + validate var req CreateShareRequest if !common.BindAndValidateJSON(c, &req) { return // Erreur déjà envoyée au client } share, err := h.shareService.CreateShare(c.Request.Context(), trackID, userID, req.Permissions, req.ExpiresAt) if err != nil { if errors.Is(err, ErrForbidden) { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusForbidden, "forbidden") return } if errors.Is(err, ErrTrackNotFound) { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusNotFound, "track not found") return } // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusInternalServerError, "failed to create share") return } c.JSON(http.StatusOK, gin.H{"share": share}) } // GetSharedTrack récupère un track via son token de partage // GET /api/v1/tracks/shared/:token // BE-API-029: Implement shared track access endpoint validation func (h *TrackHandler) GetSharedTrack(c *gin.Context) { token := c.Param("token") if token == "" { handlers.RespondWithAppError(c, apperrors.NewValidationError("share token is required")) return } if h.shareService == nil { handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "share service not available", nil)) return } share, err := h.shareService.ValidateShareToken(c.Request.Context(), token) if err != nil { if errors.Is(err, services.ErrShareNotFound) { handlers.RespondWithAppError(c, apperrors.NewNotFoundError("share")) return } if errors.Is(err, services.ErrShareExpired) { handlers.RespondWithAppError(c, apperrors.NewForbiddenError("share link expired")) return } handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to validate share token", err)) return } // Récupérer le track track, err := h.trackService.GetTrackByID(c.Request.Context(), share.TrackID) if err != nil { if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { handlers.RespondWithAppError(c, apperrors.NewNotFoundError("track")) return } handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get track", err)) return } // BE-API-029: Standardize response format handlers.RespondSuccess(c, http.StatusOK, gin.H{ "track": track, "share": share, }) } // RevokeShare révoque un lien de partage // DELETE /api/v1/tracks/share/:id // BE-API-028: Implement track share revoke endpoint validation func (h *TrackHandler) RevokeShare(c *gin.Context) { // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() userID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } shareIDStr := c.Param("id") if shareIDStr == "" { handlers.RespondWithAppError(c, apperrors.NewValidationError("share id is required")) return } // MIGRATION UUID: ShareID is UUID shareID, err := uuid.Parse(shareIDStr) if err != nil { handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid share id")) return } if h.shareService == nil { handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "share service not available", nil)) return } err = h.shareService.RevokeShare(c.Request.Context(), shareID, userID) if err != nil { if errors.Is(err, services.ErrShareNotFound) { handlers.RespondWithAppError(c, apperrors.NewNotFoundError("share")) return } if errors.Is(err, services.ErrForbidden) { handlers.RespondWithAppError(c, apperrors.NewForbiddenError("forbidden")) return } handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to revoke share", err)) return } // BE-API-028: Standardize response format handlers.RespondSuccess(c, http.StatusOK, gin.H{"message": "share revoked"}) } // StreamCallbackRequest represents the request for stream status callback type StreamCallbackRequest struct { Status string `json:"status" binding:"required" validate:"required,oneof=completed failed processing"` ManifestURL string `json:"manifest_url" validate:"omitempty,url"` Error string `json:"error"` } // HandleStreamCallback handles the callback from stream server func (h *TrackHandler) HandleStreamCallback(c *gin.Context) { trackIDStr := c.Param("id") // MIGRATION UUID: TrackID is UUID trackID, err := uuid.Parse(trackIDStr) if err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusBadRequest, "invalid track id") return } // MOD-P1-002: Utiliser helper centralisé pour bind + validate var req StreamCallbackRequest if !common.BindAndValidateJSON(c, &req) { return // Erreur déjà envoyée au client } if err := h.trackService.UpdateStreamStatus(c.Request.Context(), trackID, req.Status, req.ManifestURL); err != nil { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusInternalServerError, "failed to update stream status") return } c.JSON(http.StatusOK, gin.H{"message": "status updated"}) } // GetTrackStats stub func (h *TrackHandler) GetTrackStats(c *gin.Context) { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusNotImplemented, "Not implemented") } // GetTrackHistory stub func (h *TrackHandler) GetTrackHistory(c *gin.Context) { // MOD-P2-003: Utiliser AppError au lieu de gin.H h.respondWithError(c, http.StatusNotImplemented, "Not implemented") } // getContentType retourne le Content-Type approprié pour un format audio func getContentType(format string) string { switch strings.ToUpper(format) { case "MP3": return "audio/mpeg" case "FLAC": return "audio/flac" case "WAV": return "audio/wav" case "OGG": return "audio/ogg" case "AAC", "M4A": return "audio/aac" default: return "application/octet-stream" } } // RecordPlayRequest représente la requête pour enregistrer un événement de lecture // BE-API-019: Implement track play analytics endpoint type RecordPlayRequest struct { PlayTime int `json:"play_time,omitempty" binding:"omitempty,min=0"` // seconds, optional, defaults to 0 } // RecordPlay enregistre un événement de lecture pour un track // POST /api/v1/tracks/:id/play // BE-API-019: Implement track play analytics endpoint func (h *TrackHandler) RecordPlay(c *gin.Context) { if h.playbackAnalyticsService == nil { h.respondWithError(c, http.StatusInternalServerError, "playback analytics service not available") return } // Récupérer l'ID du track depuis l'URL trackIDStr := c.Param("id") trackID, err := uuid.Parse(trackIDStr) if err != nil { h.respondWithError(c, http.StatusBadRequest, "invalid track id") return } // Récupérer l'ID utilisateur du contexte userID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } // Parser la requête (optionnelle, peut être vide) var req RecordPlayRequest if c.Request.ContentLength > 0 { if err := c.ShouldBindJSON(&req); err != nil { // Si le body est présent mais invalide, retourner une erreur h.respondWithError(c, http.StatusBadRequest, "invalid request body") return } } // Créer un événement d'analytics basique // Note: Le service RecordPlayback vérifie que le track existe playTime := req.PlayTime if playTime < 0 { playTime = 0 } analytics := &models.PlaybackAnalytics{ TrackID: trackID, UserID: userID, PlayTime: playTime, PauseCount: 0, SeekCount: 0, CompletionRate: 0, // Sera calculé par le service si track.Duration > 0 StartedAt: time.Now(), EndedAt: nil, } // Enregistrer l'événement via le service err = h.playbackAnalyticsService.RecordPlayback(c.Request.Context(), analytics) if err != nil { h.respondWithError(c, http.StatusInternalServerError, "failed to record play event") return } // Retourner le succès handlers.RespondSuccess(c, http.StatusOK, gin.H{ "message": "Play event recorded", "id": analytics.ID.String(), }) } // RestoreVersion restaure une version spécifique d'un track // POST /api/v1/tracks/:id/versions/:versionId/restore // BE-API-014: Implement track versions restore endpoint func (h *TrackHandler) RestoreVersion(c *gin.Context) { if h.versionService == nil { h.respondWithError(c, http.StatusInternalServerError, "version service not available") return } // Récupérer l'ID du track depuis l'URL trackIDStr := c.Param("id") trackID, err := uuid.Parse(trackIDStr) if err != nil { h.respondWithError(c, http.StatusBadRequest, "invalid track id") return } // Récupérer l'ID de la version depuis l'URL versionIDStr := c.Param("versionId") versionID, err := uuid.Parse(versionIDStr) if err != nil { h.respondWithError(c, http.StatusBadRequest, "invalid version id") return } // Récupérer l'ID utilisateur du contexte userID, ok := h.getUserID(c) if !ok { return // Erreur déjà envoyée par getUserID } // Restaurer la version err = h.versionService.RestoreVersion(c.Request.Context(), trackID, versionID, userID) if err != nil { if errors.Is(err, services.ErrTrackNotFound) { h.respondWithError(c, http.StatusNotFound, "track not found") return } if errors.Is(err, services.ErrVersionNotFound) { h.respondWithError(c, http.StatusNotFound, "version not found") return } if errors.Is(err, services.ErrForbidden) { h.respondWithError(c, http.StatusForbidden, "forbidden: only track owner can restore versions") return } h.respondWithError(c, http.StatusInternalServerError, "failed to restore version") return } c.JSON(http.StatusOK, gin.H{"message": "Version restored successfully"}) }