diff --git a/veza-backend-api/internal/core/track/service.go b/veza-backend-api/internal/core/track/service.go index 32b198517..14e8b3304 100644 --- a/veza-backend-api/internal/core/track/service.go +++ b/veza-backend-api/internal/core/track/service.go @@ -164,6 +164,33 @@ func (s *TrackService) IsS3Backend() bool { return s.storageBackend == "s3" && s.s3Service != nil } +// GetStorageURL returns a signed URL for a track's file when the track row +// carries storage_backend='s3'. Returns ("", false, nil) for local-backed +// tracks — the caller must fall back to filesystem serving. +// +// v1.0.8 Phase 2 — handlers (StreamTrack, DownloadTrack) use this to emit +// a 302 redirect to MinIO/S3 for tracks that were uploaded under s3 mode. +// TTL is caller-provided: 15min for streaming, 30min for downloads, 1h for +// the transcoder. +func (s *TrackService) GetStorageURL(ctx context.Context, track *models.Track, ttl time.Duration) (string, bool, error) { + if track == nil { + return "", false, fmt.Errorf("track is nil") + } + if track.StorageBackend != "s3" || track.StorageKey == nil || *track.StorageKey == "" { + return "", false, nil + } + if s.s3Service == nil { + // Row says s3 but no S3 service wired. Should be prevented by + // Config.ValidateForEnvironment rule 11, but guard here anyway. + return "", false, fmt.Errorf("track %s is s3-backed but TrackService has no S3 service configured", track.ID) + } + url, err := s.s3Service.GetSignedURL(ctx, *track.StorageKey, ttl) + if err != nil { + return "", false, fmt.Errorf("generate signed URL for track %s: %w", track.ID, err) + } + return url, true, nil +} + // ValidateTrackFile valide le format et la taille d'un fichier audio func (s *TrackService) ValidateTrackFile(fileHeader *multipart.FileHeader) error { // Valider la taille diff --git a/veza-backend-api/internal/core/track/service_async_test.go b/veza-backend-api/internal/core/track/service_async_test.go index 65f162a44..e6659106f 100644 --- a/veza-backend-api/internal/core/track/service_async_test.go +++ b/veza-backend-api/internal/core/track/service_async_test.go @@ -348,6 +348,37 @@ func TestUploadTrack_S3Backend_UploadsToS3(t *testing.T) { } } +func TestGetStorageURL(t *testing.T) { + logger := zaptest.NewLogger(t) + service := NewTrackService(nil, logger, t.TempDir()) + fake := &fakeS3Storage{} + service.SetS3Storage(fake, "s3", "bucket") + + // Local-backed track — returns empty, not s3 + key := "" + localTrack := &models.Track{ID: uuid.New(), StorageBackend: "local", StorageKey: &key} + url, isS3, err := service.GetStorageURL(context.Background(), localTrack, 5*time.Minute) + require.NoError(t, err) + assert.False(t, isS3, "local backend must not return isS3=true") + assert.Empty(t, url) + + // S3-backed track — returns signed URL + s3Key := "tracks/u/t.mp3" + s3Track := &models.Track{ID: uuid.New(), StorageBackend: "s3", StorageKey: &s3Key} + url, isS3, err = service.GetStorageURL(context.Background(), s3Track, 5*time.Minute) + require.NoError(t, err) + assert.True(t, isS3) + assert.Contains(t, url, s3Key) + assert.Contains(t, url, "ttl=5m0s", "fake returns TTL in URL for assertion") + + // S3-backed track but nil StorageKey — treated as local (defensive) + s3BrokenTrack := &models.Track{ID: uuid.New(), StorageBackend: "s3", StorageKey: nil} + url, isS3, err = service.GetStorageURL(context.Background(), s3BrokenTrack, 5*time.Minute) + require.NoError(t, err) + assert.False(t, isS3) + assert.Empty(t, url) +} + func TestUploadTrack_S3Backend_NilS3Service_FallsBackToLocal(t *testing.T) { // Defensive: storageBackend="s3" but nil s3Service must fall back to local, // not crash. Matches the guard in copyFileAsync (both conditions required). diff --git a/veza-backend-api/internal/core/track/track_hls_handler.go b/veza-backend-api/internal/core/track/track_hls_handler.go index 7c1be6ccc..5275962be 100644 --- a/veza-backend-api/internal/core/track/track_hls_handler.go +++ b/veza-backend-api/internal/core/track/track_hls_handler.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/google/uuid" @@ -156,7 +157,22 @@ func (h *TrackHandler) DownloadTrack(c *gin.Context) { } } - // Vérifier que le fichier existe + // v1.0.8 Phase 2 — S3-backed tracks: redirect to a signed URL. MinIO + // honors Range headers so the client can still seek within the audio. + // TTL 30min — downloads tend to be one-shot and may be slow on mobile. + if url, ok, err := h.trackService.GetStorageURL(c.Request.Context(), track, 30*time.Minute); err != nil { + h.trackService.logger.Error("Failed to build signed URL for S3-backed track download", + zap.String("track_id", trackID.String()), + zap.Error(err), + ) + h.respondWithError(c, http.StatusInternalServerError, "failed to build track URL") + return + } else if ok { + c.Redirect(http.StatusFound, url) + return + } + + // Local backend — serve from FS. 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") @@ -233,6 +249,22 @@ func (h *TrackHandler) StreamTrack(c *gin.Context) { return } + // v1.0.8 Phase 2 — S3-backed tracks: redirect to a signed URL so the + // browser fetches bytes directly from MinIO. TTL 15min — shorter than + // download because streams are typically consumed within minutes; long + // enough to survive one full track (even 30-minute mixes). + if url, ok, err := h.trackService.GetStorageURL(c.Request.Context(), track, 15*time.Minute); err != nil { + h.trackService.logger.Error("Failed to build signed URL for S3-backed track stream", + zap.String("track_id", trackID.String()), + zap.Error(err), + ) + h.respondWithError(c, http.StatusInternalServerError, "failed to build track URL") + return + } else if ok { + c.Redirect(http.StatusFound, url) + return + } + file, err := os.Open(track.FilePath) if err != nil { if os.IsNotExist(err) {