Phase 1 - Backend tests: - Add PlaybackAnalytics to AutoMigrate in setupTestTrackHandler - Create migration 081_create_playback_analytics.sql for production - PlaybackAnalyticsService: return ErrTrackNotFound for missing track - RecordPlay handler: return 404 when track not found - CreateShare: use RespondSuccess, fix services.ErrTrackNotFound/ErrForbidden - GetTrackLikes, UnlikeTrack: use RespondSuccess for consistent response - GetUserLikedTracks test: fix route /users/:id/likes and params - GetSharedTrack_InvalidToken: set share service in test Phase 4 - Job queue transcoding: - Add EnqueueTranscodingJob to JobEnqueuer interface - Add TypeTranscoding and processTranscodingJob (stub) in JobWorker - MockJobEnqueuer: implement EnqueueTranscodingJob Phase 5 - Gamification cleanup: - Move api_manager.go to internal/api/archive/ - Add archive/README.md documenting archived modules - Update TODOS_AUDIT.md and FEATURE_STATUS.md
2061 lines
70 KiB
Go
2061 lines
70 KiB
Go
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
|
|
licenseChecker services.TrackDownloadLicenseChecker // A04: Verify paid track download rights
|
|
notificationService *services.NotificationService // Phase 2.2: Optional, for like notifications
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetLicenseChecker définit le vérificateur de licence pour les téléchargements de tracks payants (A04)
|
|
func (h *TrackHandler) SetLicenseChecker(checker services.TrackDownloadLicenseChecker) {
|
|
h.licenseChecker = checker
|
|
}
|
|
|
|
// SetNotificationService définit le service de notifications (Phase 2.2)
|
|
func (h *TrackHandler) SetNotificationService(notificationService *services.NotificationService) {
|
|
h.notificationService = notificationService
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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 (TrackUploadService utilise uuid.UUID)
|
|
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, checksum, 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 checksum SHA256
|
|
if err := h.trackUploadService.UpdateUploadStatus(c.Request.Context(), track.ID, models.TrackStatusUploading, fmt.Sprintf("Upload completed, checksum: %s", checksum)); 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": checksum, // SHA256 (64 hex), legacy key for API compatibility
|
|
})
|
|
}
|
|
|
|
// 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 — return 400 if out of bounds (no silent normalization)
|
|
var pageInt, limitInt int
|
|
if _, err := fmt.Sscanf(page, "%d", &pageInt); err != nil || pageInt < 1 {
|
|
response.BadRequest(c, "pagination: page must be >= 1 and limit must be between 1 and 100")
|
|
return
|
|
}
|
|
if _, err := fmt.Sscanf(limit, "%d", &limitInt); err != nil || limitInt < 1 || limitInt > 100 {
|
|
response.BadRequest(c, "pagination: page must be >= 1 and limit must be between 1 and 100")
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Phase 2.2: Create notification for track creator (skip if user likes own track)
|
|
if h.notificationService != nil {
|
|
track, err := h.trackService.GetTrackByID(c.Request.Context(), trackID)
|
|
if err == nil && track.UserID != userID {
|
|
link := "/tracks/" + trackID.String()
|
|
if err := h.notificationService.CreateNotification(track.UserID, "like", "New like", "Someone liked your track", link); err != nil {
|
|
// Log but don't fail the request
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
handlers.RespondSuccess(c, 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)
|
|
}
|
|
}
|
|
|
|
handlers.RespondSuccess(c, 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
|
|
}
|
|
}
|
|
if params.Limit > 100 {
|
|
params.Limit = 100
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// A04: Si le track est vendu comme produit et que l'utilisateur n'est pas le propriétaire,
|
|
// vérifier qu'il a une licence valide
|
|
if track.UserID != userID && h.licenseChecker != nil {
|
|
soldAsProduct, err := h.licenseChecker.IsTrackSoldAsProduct(c.Request.Context(), trackID)
|
|
if err != nil {
|
|
h.trackService.logger.Error("Failed to check if track is sold as product", zap.Error(err), zap.String("track_id", trackID.String()))
|
|
h.respondWithError(c, http.StatusInternalServerError, "failed to verify download rights")
|
|
return
|
|
}
|
|
if soldAsProduct {
|
|
hasLicense, err := h.licenseChecker.HasPaidTrackDownloadRight(c.Request.Context(), userID, trackID)
|
|
if err != nil {
|
|
h.trackService.logger.Error("Failed to check download license", zap.Error(err), zap.String("track_id", trackID.String()))
|
|
h.respondWithError(c, http.StatusInternalServerError, "failed to verify download rights")
|
|
return
|
|
}
|
|
if !hasLicense {
|
|
h.respondWithError(c, http.StatusForbidden, "purchase required to download this track")
|
|
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, services.ErrForbidden) {
|
|
// MOD-P2-003: Utiliser AppError au lieu de gin.H
|
|
h.respondWithError(c, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if errors.Is(err, services.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
|
|
}
|
|
|
|
handlers.RespondSuccess(c, 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 returns track statistics (plays, likes, views, etc.)
|
|
// GET /api/v1/tracks/:id/stats
|
|
func (h *TrackHandler) GetTrackStats(c *gin.Context) {
|
|
trackIDStr := c.Param("id")
|
|
if trackIDStr == "" {
|
|
h.respondWithError(c, http.StatusBadRequest, "track id is required")
|
|
return
|
|
}
|
|
trackID, err := uuid.Parse(trackIDStr)
|
|
if err != nil {
|
|
h.respondWithError(c, http.StatusBadRequest, "invalid track id")
|
|
return
|
|
}
|
|
|
|
stats, err := h.trackService.GetTrackStats(c.Request.Context(), trackID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrTrackNotFound) {
|
|
h.respondWithError(c, http.StatusNotFound, "track not found")
|
|
return
|
|
}
|
|
h.respondWithError(c, http.StatusInternalServerError, "failed to get track stats")
|
|
return
|
|
}
|
|
|
|
// Map to response format expected by frontend
|
|
avgDuration := float64(0)
|
|
if stats.Views > 0 {
|
|
avgDuration = float64(stats.TotalPlayTime) / float64(stats.Views)
|
|
}
|
|
resp := gin.H{
|
|
"total_plays": stats.Views,
|
|
"unique_listeners": 0, // TrackService does not compute; use /analytics/tracks/:id/stats for full analytics
|
|
"average_duration": avgDuration,
|
|
"completion_rate": 0,
|
|
"views": stats.Views,
|
|
"likes": stats.Likes,
|
|
"comments": stats.Comments,
|
|
"total_play_time": stats.TotalPlayTime,
|
|
"downloads": stats.Downloads,
|
|
}
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{"stats": resp})
|
|
}
|
|
|
|
// GetTrackHistory returns modification history for a track
|
|
// GET /api/v1/tracks/:id/history
|
|
func (h *TrackHandler) GetTrackHistory(c *gin.Context) {
|
|
if h.historyService == nil {
|
|
h.respondWithError(c, http.StatusInternalServerError, "history service not available")
|
|
return
|
|
}
|
|
|
|
trackIDStr := c.Param("id")
|
|
if trackIDStr == "" {
|
|
h.respondWithError(c, http.StatusBadRequest, "track id is required")
|
|
return
|
|
}
|
|
trackID, err := uuid.Parse(trackIDStr)
|
|
if err != nil {
|
|
h.respondWithError(c, http.StatusBadRequest, "invalid track id")
|
|
return
|
|
}
|
|
|
|
limit := 50
|
|
offset := 0
|
|
if l := c.Query("limit"); l != "" {
|
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
if o := c.Query("offset"); o != "" {
|
|
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
|
offset = parsed
|
|
}
|
|
}
|
|
|
|
histories, total, err := h.historyService.GetHistory(c.Request.Context(), trackID, limit, offset)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrTrackNotFound) {
|
|
h.respondWithError(c, http.StatusNotFound, "track not found")
|
|
return
|
|
}
|
|
h.respondWithError(c, http.StatusInternalServerError, "failed to get track history")
|
|
return
|
|
}
|
|
|
|
// Map models to response format
|
|
historyItems := make([]gin.H, 0, len(histories))
|
|
for _, item := range histories {
|
|
historyItems = append(historyItems, gin.H{
|
|
"id": item.ID.String(),
|
|
"track_id": item.TrackID.String(),
|
|
"user_id": item.UserID.String(),
|
|
"action": string(item.Action),
|
|
"old_value": item.OldValue,
|
|
"new_value": item.NewValue,
|
|
"created_at": item.CreatedAt,
|
|
})
|
|
}
|
|
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
|
"history": historyItems,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
}
|
|
|
|
// 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 {
|
|
if errors.Is(err, services.ErrTrackNotFound) {
|
|
h.respondWithError(c, http.StatusNotFound, "track not found")
|
|
return
|
|
}
|
|
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"})
|
|
}
|