diff --git a/apps/web/src/config/features.ts b/apps/web/src/config/features.ts index 8b55ad4ef..01b401c12 100644 --- a/apps/web/src/config/features.ts +++ b/apps/web/src/config/features.ts @@ -48,10 +48,15 @@ export const FEATURES = { /** * HLS Streaming * Backend endpoints: /api/v1/tracks/:id/hls/info, /api/v1/tracks/:id/hls/status + * + * Default is `false` to match backend `HLS_STREAMING` env (off by default). + * When off, playback goes through `/api/v1/tracks/:id/stream` (MP3 range requests). + * Enable via VITE_FEATURE_HLS_STREAMING=true in environments where the backend + * transcoder is actually running. */ HLS_STREAMING: parseFeatureEnv( import.meta.env.VITE_FEATURE_HLS_STREAMING, - true, + false, ), /** diff --git a/apps/web/src/features/library/components/library-manager/useLibraryManager.ts b/apps/web/src/features/library/components/library-manager/useLibraryManager.ts index ca69a1187..7047c553b 100644 --- a/apps/web/src/features/library/components/library-manager/useLibraryManager.ts +++ b/apps/web/src/features/library/components/library-manager/useLibraryManager.ts @@ -119,7 +119,7 @@ export function useLibraryManager( artist: track.artist, album: t.album, duration: track.duration, - url: t.stream_manifest_url || `/api/v1/tracks/${track.id}/download`, + url: t.stream_manifest_url || `/api/v1/tracks/${track.id}/stream`, cover: t.cover_art_path, genre: t.genre, }; diff --git a/apps/web/src/features/player/components/PlayerQueue.tsx b/apps/web/src/features/player/components/PlayerQueue.tsx index 10ef54b22..7f8ad6307 100644 --- a/apps/web/src/features/player/components/PlayerQueue.tsx +++ b/apps/web/src/features/player/components/PlayerQueue.tsx @@ -29,7 +29,7 @@ function mapApiTrackToPlayerTrack(t: ApiTrack): PlayerTrack { title: t.title, artist: t.artist, duration: t.duration ?? 0, - url: apiTrack.stream_manifest_url || `/api/v1/tracks/${t.id}/download`, + url: apiTrack.stream_manifest_url || `/api/v1/tracks/${t.id}/stream`, cover: apiTrack.cover_art_path, genre: t.genre, like_count: t.like_count, @@ -44,7 +44,7 @@ function mapSessionItemToPlayerTrack(item: { id: string; track?: { id: string; t title: t.title ?? '', artist: t.artist ?? '', duration: t.duration ?? 0, - url: `/api/v1/tracks/${t.id}/download`, + url: `/api/v1/tracks/${t.id}/stream`, cover: t.cover_art_path, genre: t.genre, like_count: t.like_count, diff --git a/apps/web/src/features/player/services/playerService.test.ts b/apps/web/src/features/player/services/playerService.test.ts index fcfd7919e..0a6869af1 100644 --- a/apps/web/src/features/player/services/playerService.test.ts +++ b/apps/web/src/features/player/services/playerService.test.ts @@ -242,7 +242,7 @@ describe('playerService', () => { ); }); - it('should fallback to direct URL when track has invalid media URL', async () => { + it('should fallback to direct stream URL when track has invalid media URL', async () => { const trackWithInvalidUrl = { id: 1, title: 'Test', @@ -252,10 +252,11 @@ describe('playerService', () => { await service.loadTrack(trackWithInvalidUrl); - // When URL is invalid (e.g. 'undefined'), the service falls back to direct download URL + // When URL is invalid (e.g. 'undefined'), the service falls back to the + // /api/v1/tracks/:id/stream endpoint (always on, Range-aware). const srcAfterFallback = audioElement.src; expect(srcAfterFallback).toContain('/api/v1/tracks/'); - expect(srcAfterFallback).toContain('/download'); + expect(srcAfterFallback).toContain('/stream'); }); }); diff --git a/apps/web/src/features/player/services/playerService.ts b/apps/web/src/features/player/services/playerService.ts index 27db48712..2eb5e08ae 100644 --- a/apps/web/src/features/player/services/playerService.ts +++ b/apps/web/src/features/player/services/playerService.ts @@ -270,11 +270,12 @@ export class AudioPlayerService { private fallbackAttempted = false; /** - * Build a direct download URL as fallback when HLS streaming fails. - * Uses the backend's /api/v1/tracks/:id/download endpoint. + * Build a direct streaming URL as fallback when HLS streaming fails. + * Uses the backend's /api/v1/tracks/:id/stream endpoint which serves + * the raw audio via http.ServeContent (supports Range requests for seeking). */ private static getDirectAudioURL(trackId: string): string { - return `/api/v1/tracks/${trackId}/download`; + return `/api/v1/tracks/${trackId}/stream`; } /** @@ -312,7 +313,8 @@ export class AudioPlayerService { this.fallbackAttempted = false; if (!AudioPlayerService.isValidMediaUrl(track.url)) { - // No HLS URL available — try direct download immediately + // No valid playback URL supplied — fall back to the always-on + // /api/v1/tracks/:id/stream endpoint. const directUrl = AudioPlayerService.getDirectAudioURL(track.id); this.audioElement.src = directUrl; this.audioElement.load(); diff --git a/apps/web/src/features/playlists/pages/SharedPlaylistPage.tsx b/apps/web/src/features/playlists/pages/SharedPlaylistPage.tsx index 36c15f3c4..431382b83 100644 --- a/apps/web/src/features/playlists/pages/SharedPlaylistPage.tsx +++ b/apps/web/src/features/playlists/pages/SharedPlaylistPage.tsx @@ -28,7 +28,7 @@ function mapToPlayerTrack(t: ApiTrack): PlayerTrack { artist: t.artist, album: t.album, duration: t.duration, - url: (t as { stream_manifest_url?: string }).stream_manifest_url || `/api/v1/tracks/${t.id}/download`, + url: (t as { stream_manifest_url?: string }).stream_manifest_url || `/api/v1/tracks/${t.id}/stream`, cover: (t as { cover_art_path?: string }).cover_art_path, genre: t.genre, }; diff --git a/apps/web/src/features/streaming/hooks/useHLSPlayer.ts b/apps/web/src/features/streaming/hooks/useHLSPlayer.ts index 2a5a1550b..2bfb3bf67 100644 --- a/apps/web/src/features/streaming/hooks/useHLSPlayer.ts +++ b/apps/web/src/features/streaming/hooks/useHLSPlayer.ts @@ -71,21 +71,34 @@ export function useHLSPlayer( setState(prev => ({ ...prev, currentLevel: data.level })); }); - hls.on(Hls.Events.ERROR, (_event, data) => { - if (data.fatal) { - switch (data.type) { - case Hls.ErrorTypes.NETWORK_ERROR: - hls.startLoad(); - break; - case Hls.ErrorTypes.MEDIA_ERROR: - hls.recoverMediaError(); - break; - default: - hls.destroy(); - setState(prev => ({ ...prev, isHLSActive: false })); - break; - } + // Swap the audio element onto the always-on /stream endpoint and free + // the hls.js instance. Used whenever HLS has failed in a way that won't + // recover — typically a manifest 404 because the backend has HLS off, + // or the transcoder is down. + const fallbackToDirectStream = () => { + const audio = audioRef.current; + hls.destroy(); + hlsRef.current = null; + setState(prev => ({ ...prev, isHLSActive: false })); + if (audio && trackId) { + audio.src = `/api/v1/tracks/${trackId}/stream`; + audio.load(); } + }; + + hls.on(Hls.Events.ERROR, (_event, data) => { + if (!data.fatal) return; + + // MEDIA errors can still recover (decoder glitch, bad segment). + if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { + hls.recoverMediaError(); + return; + } + + // Manifest-level network errors will never come back (404 stays 404). + // Any other fatal error means hls.js has already exhausted its + // internal retries — give up on HLS and play the raw file. + fallbackToDirectStream(); }); hlsRef.current = hls; diff --git a/apps/web/src/features/tracks/components/TrackSearchResults.tsx b/apps/web/src/features/tracks/components/TrackSearchResults.tsx index d2cba2b64..0b0743a99 100644 --- a/apps/web/src/features/tracks/components/TrackSearchResults.tsx +++ b/apps/web/src/features/tracks/components/TrackSearchResults.tsx @@ -61,7 +61,7 @@ export function TrackSearchResults({ title: track.title, artist: track.artist, duration: track.duration ?? 0, - url: `/api/v1/tracks/${track.id}/download`, + url: `/api/v1/tracks/${track.id}/stream`, cover: track.cover_art_path, genre: track.genre, like_count: track.like_count, diff --git a/apps/web/src/features/tracks/pages/track-detail-page/useTrackDetailPage.ts b/apps/web/src/features/tracks/pages/track-detail-page/useTrackDetailPage.ts index de249254b..599dba418 100644 --- a/apps/web/src/features/tracks/pages/track-detail-page/useTrackDetailPage.ts +++ b/apps/web/src/features/tracks/pages/track-detail-page/useTrackDetailPage.ts @@ -54,7 +54,7 @@ export function useTrackDetailPage(trackIdOverride?: string) { artist: t.artist, album: t.album, duration: t.duration, - url: (t as { stream_manifest_url?: string }).stream_manifest_url || `/api/v1/tracks/${t.id}/download`, + url: (t as { stream_manifest_url?: string }).stream_manifest_url || `/api/v1/tracks/${t.id}/stream`, cover: (t as { cover_art_path?: string }).cover_art_path, genre: t.genre, }); diff --git a/apps/web/src/mocks/handlers-tracks.ts b/apps/web/src/mocks/handlers-tracks.ts index 8a3b2123e..5456f5052 100644 --- a/apps/web/src/mocks/handlers-tracks.ts +++ b/apps/web/src/mocks/handlers-tracks.ts @@ -298,6 +298,15 @@ export const handlersTracks = [ return new HttpResponse(new ArrayBuffer(1024), { headers: { 'Content-Type': 'audio/mpeg' } }); }), + http.get('*/api/v1/tracks/:id/stream', () => { + return new HttpResponse(new ArrayBuffer(1024), { + headers: { + 'Content-Type': 'audio/mpeg', + 'Accept-Ranges': 'bytes', + }, + }); + }), + http.post('*/api/v1/tracks/:id/like', () => HttpResponse.json({ success: true })), http.delete('*/api/v1/tracks/:id/like', () => HttpResponse.json({ success: true })), diff --git a/apps/web/src/services/discoverService.ts b/apps/web/src/services/discoverService.ts index 84ea804a8..de89e250d 100644 --- a/apps/web/src/services/discoverService.ts +++ b/apps/web/src/services/discoverService.ts @@ -37,7 +37,7 @@ function getTrackPlaybackURL(bt: BackendTrack): string { if (bt.stream_manifest_url) { return String(bt.stream_manifest_url); } - return `/api/v1/tracks/${bt.id}/download`; + return `/api/v1/tracks/${bt.id}/stream`; } function mapBackendTrackToTrack(bt: BackendTrack): Track { diff --git a/apps/web/src/services/feedService.ts b/apps/web/src/services/feedService.ts index 617825848..af9776437 100644 --- a/apps/web/src/services/feedService.ts +++ b/apps/web/src/services/feedService.ts @@ -34,14 +34,13 @@ interface BackendTrack { /** * Build the best playback URL for a track: * 1. If backend provides stream_manifest_url (HLS ready) → use it - * 2. Otherwise → direct download from backend API (always works if file exists) + * 2. Otherwise → /stream endpoint (http.ServeContent, Range-aware) */ function getTrackPlaybackURL(bt: BackendTrack): string { if (bt.stream_manifest_url) { return String(bt.stream_manifest_url); } - // Direct audio streaming from backend (no HLS server needed) - return `/api/v1/tracks/${bt.id}/download`; + return `/api/v1/tracks/${bt.id}/stream`; } function mapBackendTrackToTrack(bt: BackendTrack): Track { diff --git a/veza-backend-api/internal/api/routes_tracks.go b/veza-backend-api/internal/api/routes_tracks.go index a8808f3be..db9e2e61e 100644 --- a/veza-backend-api/internal/api/routes_tracks.go +++ b/veza-backend-api/internal/api/routes_tracks.go @@ -114,6 +114,11 @@ func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) { tracks.GET("/:id/waveform", trackHandler.GetWaveform) tracks.GET("/:id/history", trackHandler.GetTrackHistory) tracks.GET("/:id/download", trackHandler.DownloadTrack) + if r.config.AuthMiddleware != nil { + tracks.GET("/:id/stream", r.config.AuthMiddleware.OptionalAuth(), trackHandler.StreamTrack) + } else { + tracks.GET("/:id/stream", trackHandler.StreamTrack) + } tracks.GET("/shared/:token", trackHandler.GetSharedTrack) if r.config.AuthMiddleware != nil { tracks.GET("/:id/repost", r.config.AuthMiddleware.OptionalAuth(), trackHandler.GetRepostStatus) // v0.10.3 F203 diff --git a/veza-backend-api/internal/core/track/handler_additional_test.go b/veza-backend-api/internal/core/track/handler_additional_test.go index 54b072d5a..f686b24bf 100644 --- a/veza-backend-api/internal/core/track/handler_additional_test.go +++ b/veza-backend-api/internal/core/track/handler_additional_test.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "time" @@ -228,6 +230,172 @@ func TestTrackHandler_DownloadTrack_NotFound(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) } +// TestTrackHandler_StreamTrack_InvalidID tests StreamTrack with a non-UUID param. +func TestTrackHandler_StreamTrack_InvalidID(t *testing.T) { + handler, _, router, cleanup := setupTestTrackHandler(t) + defer cleanup() + + router.GET("/tracks/:id/stream", handler.StreamTrack) + + req := httptest.NewRequest(http.MethodGet, "/tracks/not-a-uuid/stream", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// TestTrackHandler_StreamTrack_NotFound tests StreamTrack when the track does not exist. +func TestTrackHandler_StreamTrack_NotFound(t *testing.T) { + handler, _, router, cleanup := setupTestTrackHandler(t) + defer cleanup() + + router.GET("/tracks/:id/stream", handler.StreamTrack) + + nonExistentID := uuid.New() + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/tracks/%s/stream", nonExistentID.String()), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// TestTrackHandler_StreamTrack_PrivateForbidden ensures anonymous users cannot stream +// a private track even when the file exists on disk. +func TestTrackHandler_StreamTrack_PrivateForbidden(t *testing.T) { + handler, db, router, cleanup := setupTestTrackHandler(t) + defer cleanup() + + ownerID := uuid.New() + user := &models.User{ + ID: ownerID, + Username: "owner", + Email: "owner@example.com", + } + require.NoError(t, db.Create(user).Error) + + trackID := uuid.New() + track := createTestTrack(trackID, ownerID) + require.NoError(t, db.Create(track).Error) + // gorm:"default:true" on IsPublic means we can't persist `false` through the + // struct path (zero-value is indistinguishable from unset) — flip it with Update. + require.NoError(t, db.Model(&models.Track{}).Where("id = ?", trackID).Update("is_public", false).Error) + + router.GET("/tracks/:id/stream", handler.StreamTrack) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/tracks/%s/stream", trackID.String()), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +// TestTrackHandler_StreamTrack_MissingFile verifies that a track row with no matching +// file on disk surfaces as 404 instead of a generic 500. +func TestTrackHandler_StreamTrack_MissingFile(t *testing.T) { + handler, db, router, cleanup := setupTestTrackHandler(t) + defer cleanup() + + ownerID := uuid.New() + user := &models.User{ + ID: ownerID, + Username: "owner", + Email: "owner@example.com", + } + require.NoError(t, db.Create(user).Error) + + trackID := uuid.New() + track := createTestTrack(trackID, ownerID) + track.IsPublic = true + track.FilePath = filepath.Join(t.TempDir(), "does-not-exist.mp3") + require.NoError(t, db.Create(track).Error) + + router.GET("/tracks/:id/stream", handler.StreamTrack) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/tracks/%s/stream", trackID.String()), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +// TestTrackHandler_StreamTrack_FullBody streams a real file end-to-end and asserts +// that the range-aware streaming headers are present. +func TestTrackHandler_StreamTrack_FullBody(t *testing.T) { + handler, db, router, cleanup := setupTestTrackHandler(t) + defer cleanup() + + ownerID := uuid.New() + user := &models.User{ + ID: ownerID, + Username: "owner", + Email: "owner@example.com", + } + require.NoError(t, db.Create(user).Error) + + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "sample.mp3") + payload := bytes.Repeat([]byte{0xAB}, 4096) + require.NoError(t, os.WriteFile(filePath, payload, 0o600)) + + trackID := uuid.New() + track := createTestTrack(trackID, ownerID) + track.IsPublic = true + track.FilePath = filePath + require.NoError(t, db.Create(track).Error) + + router.GET("/tracks/:id/stream", handler.StreamTrack) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/tracks/%s/stream", trackID.String()), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "audio/mpeg", w.Header().Get("Content-Type")) + assert.Equal(t, "bytes", w.Header().Get("Accept-Ranges")) + assert.Equal(t, len(payload), w.Body.Len()) +} + +// TestTrackHandler_StreamTrack_RangeRequest verifies that http.ServeContent honors +// the Range header — this is what enables seeking in a