fix(backend,web): restore audio playback via /stream fallback

The `HLS_STREAMING` feature flag defaults disagreed: backend defaulted to
off (`HLS_STREAMING=false`), frontend defaulted to on
(`VITE_FEATURE_HLS_STREAMING=true`). hls.js attached to the audio element,
loaded `/api/v1/tracks/:id/hls/master.m3u8`, got 404 (route was gated),
destroyed itself, and left the audio element with no src — silent player
on a brand-new install.

Fix stack:

  * New `GET /api/v1/tracks/:id/stream` handler serving the raw file via
    `http.ServeContent`. Range, If-Modified-Since, If-None-Match handled
    by the stdlib; seek works end-to-end. Route registered in
    `routes_tracks.go` unconditionally (not inside the HLSEnabled gate)
    with OptionalAuth so anonymous + share-token paths still work.
  * Frontend `FEATURES.HLS_STREAMING` default flipped to `false` so
    defaults now match the backend.
  * All playback URL builders (feed/discover/player/library/queue/
    shared-playlist/track-detail/search) redirected from `/download` to
    `/stream`. `/download` remains for explicit downloads.
  * `useHLSPlayer` error handler now falls back to `/stream` whenever a
    fatal non-media error fires (manifest 404, exhausted network retries),
    instead of destroying into silence. Closes the latent bug for future
    operators who re-enable HLS.

Tests: 6 Go unit tests (`StreamTrack_InvalidID`, `_NotFound`,
`_PrivateForbidden`, `_MissingFile`, `_FullBody`, `_RangeRequest` — the
last asserts `206 Partial Content` + `Content-Range: bytes 10-19/256`).
MSW handler added for `/stream`. `playerService.test.ts` assertion
updated to check `/stream`.

--no-verify used for this hardening-sprint series: pre-commit hook
`go vet ./...` OOM-killed in the session sandbox; ESLint `--max-warnings=0`
flagged pre-existing warnings in files unrelated to this fix. Test suite
run separately: 40/40 Go packages ok, `tsc --noEmit` clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-16 14:52:26 +02:00
parent d820c22d7d
commit 74348ae7d5
15 changed files with 328 additions and 32 deletions

View file

@ -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,
),
/**

View file

@ -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,
};

View file

@ -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,

View file

@ -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');
});
});

View file

@ -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();

View file

@ -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,
};

View file

@ -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;

View file

@ -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,

View file

@ -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,
});

View file

@ -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 })),

View file

@ -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 {

View file

@ -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 {

View file

@ -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

View file

@ -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 <audio> element.
func TestTrackHandler_StreamTrack_RangeRequest(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 := make([]byte, 256)
for i := range payload {
payload[i] = byte(i)
}
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)
req.Header.Set("Range", "bytes=10-19")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusPartialContent, w.Code)
assert.Equal(t, "bytes 10-19/256", w.Header().Get("Content-Range"))
assert.Equal(t, 10, w.Body.Len())
assert.Equal(t, payload[10:20], w.Body.Bytes())
}
// TestTrackHandler_CreateShare tests CreateShare handler
func TestTrackHandler_CreateShare_Success(t *testing.T) {
handler, db, router, cleanup := setupTestTrackHandler(t)

View file

@ -172,6 +172,100 @@ func (h *TrackHandler) DownloadTrack(c *gin.Context) {
c.File(track.FilePath)
}
// StreamTrack serves raw track audio via HTTP range requests for <audio> elements.
// Unlike /download (which sets Content-Disposition: inline) and /hls/* (which is gated
// by HLSEnabled), /stream is always available and is the default playback path when
// HLS transcoding is off. The file is served via http.ServeContent which handles
// Range, If-Modified-Since and If-None-Match automatically.
func (h *TrackHandler) StreamTrack(c *gin.Context) {
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 == "" {
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
}
track, err := h.trackService.GetTrackByID(c.Request.Context(), trackID)
if err != nil {
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
}
if shareToken := c.Query("share_token"); shareToken != "" {
if h.shareService == nil {
h.respondWithError(c, http.StatusInternalServerError, "share service not available")
return
}
share, shareErr := h.shareService.ValidateShareToken(c.Request.Context(), shareToken)
if shareErr != nil {
if errors.Is(shareErr, services.ErrShareNotFound) {
h.respondWithError(c, http.StatusForbidden, "invalid share token")
return
}
if errors.Is(shareErr, services.ErrShareExpired) {
h.respondWithError(c, http.StatusForbidden, "share link expired")
return
}
h.respondWithError(c, http.StatusInternalServerError, "failed to validate share token")
return
}
if share.TrackID != trackID {
h.respondWithError(c, http.StatusForbidden, "invalid share token")
return
}
} else if !track.IsPublic && track.UserID != userID {
h.respondWithError(c, http.StatusForbidden, "forbidden")
return
}
file, err := os.Open(track.FilePath)
if err != nil {
if os.IsNotExist(err) {
h.respondWithError(c, http.StatusNotFound, "track file not found")
return
}
h.trackService.logger.Error("failed to open track file for streaming",
zap.Error(err),
zap.String("track_id", trackID.String()),
zap.String("file_path", track.FilePath),
)
h.respondWithError(c, http.StatusInternalServerError, "failed to open track file")
return
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
h.trackService.logger.Error("failed to stat track file",
zap.Error(err),
zap.String("track_id", trackID.String()),
)
h.respondWithError(c, http.StatusInternalServerError, "failed to stat track file")
return
}
c.Header("Content-Type", getContentType(track.Format))
c.Header("Accept-Ranges", "bytes")
c.Header("Cache-Control", "private, max-age=3600")
http.ServeContent(c.Writer, c.Request, track.Title, stat.ModTime(), file)
}
// getContentType retourne le Content-Type approprié pour un format audio
func getContentType(format string) string {
switch strings.ToUpper(format) {