feat(api): GET /api/tracks/{id}/stream with Range support

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 22:44:48 -04:00
parent 945b773cbd
commit 3656461b20
3 changed files with 214 additions and 0 deletions
+1
View File
@@ -20,6 +20,7 @@ func newLibraryRouter(h *handlers) chi.Router {
r.Get("/api/albums/{id}", h.handleGetAlbum)
r.Get("/api/albums/{id}/cover", h.handleGetCover)
r.Get("/api/tracks/{id}", h.handleGetTrack)
r.Get("/api/tracks/{id}/stream", h.handleGetStream)
r.Get("/api/search", h.handleSearch)
return r
}
+39
View File
@@ -147,3 +147,42 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", imageContentType(path))
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
}
// handleGetStream implements GET /api/tracks/{id}/stream. Opens the file on
// disk and delegates byte-serving to http.ServeContent, which handles Range,
// If-Modified-Since, and ETag based on the file's mod time.
func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
return
}
q := dbq.New(h.pool)
track, err := q.GetTrackByID(r.Context(), id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "not_found", "track not found")
return
}
h.logger.Error("api: get track for stream failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "server error")
return
}
f, err := os.Open(track.FilePath)
if err != nil {
// File vanished (scanner indexed it, filesystem lost it). Treat as
// 404 so clients can fall back to the next item in a queue.
writeErr(w, http.StatusNotFound, "not_found", "track file not found")
return
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
h.logger.Error("api: stat track failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "server error")
return
}
w.Header().Set("Content-Type", audioContentType(track.FileFormat))
w.Header().Set("Accept-Ranges", "bytes")
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
}
+174
View File
@@ -287,3 +287,177 @@ func TestHandleGetCover_UnknownAlbum(t *testing.T) {
t.Errorf("message = %q, want \"album not found\"", body.Error.Message)
}
}
// deterministicBytes returns n bytes whose values cycle 0..255. Using a
// fixed pattern (not crypto/rand) makes assertions easy and the test
// reproducible.
func deterministicBytes(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = byte(i % 256)
}
return b
}
func TestHandleGetStream_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
artist := seedArtist(t, pool, "A")
album := seedAlbum(t, pool, artist.ID, "X", 0)
body := deterministicBytes(32 * 1024)
track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")
req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
w := httptest.NewRecorder()
newLibraryRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "audio/mpeg" {
t.Errorf("Content-Type = %q", ct)
}
if ar := w.Header().Get("Accept-Ranges"); ar != "bytes" {
t.Errorf("Accept-Ranges = %q", ar)
}
if cl := w.Header().Get("Content-Length"); cl != "32768" {
t.Errorf("Content-Length = %q, want 32768", cl)
}
if !bytes.Equal(w.Body.Bytes(), body) {
t.Errorf("body len = %d, want %d", len(w.Body.Bytes()), len(body))
}
}
func TestHandleGetStream_RangeFromStart(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
artist := seedArtist(t, pool, "A")
album := seedAlbum(t, pool, artist.ID, "X", 0)
body := deterministicBytes(32 * 1024)
track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")
req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
req.Header.Set("Range", "bytes=0-99")
w := httptest.NewRecorder()
newLibraryRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", w.Code)
}
if cr := w.Header().Get("Content-Range"); cr != "bytes 0-99/32768" {
t.Errorf("Content-Range = %q", cr)
}
if got := w.Body.Bytes(); !bytes.Equal(got, body[:100]) {
t.Errorf("body mismatch: len=%d, want 100", len(got))
}
}
func TestHandleGetStream_RangeFromMiddleToEnd(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
artist := seedArtist(t, pool, "A")
album := seedAlbum(t, pool, artist.ID, "X", 0)
body := deterministicBytes(32 * 1024)
track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")
req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
req.Header.Set("Range", "bytes=32000-")
w := httptest.NewRecorder()
newLibraryRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusPartialContent {
t.Fatalf("status = %d", w.Code)
}
if cr := w.Header().Get("Content-Range"); cr != "bytes 32000-32767/32768" {
t.Errorf("Content-Range = %q", cr)
}
if got := w.Body.Bytes(); !bytes.Equal(got, body[32000:]) {
t.Errorf("body mismatch: len=%d, want %d", len(got), len(body)-32000)
}
}
func TestHandleGetStream_IfModifiedSinceReturns304(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
artist := seedArtist(t, pool, "A")
album := seedAlbum(t, pool, artist.ID, "X", 0)
body := deterministicBytes(1024)
track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")
info, err := os.Stat(track.FilePath)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
req.Header.Set("If-Modified-Since", info.ModTime().UTC().Format(http.TimeFormat))
w := httptest.NewRecorder()
newLibraryRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusNotModified {
t.Fatalf("status = %d, want 304", w.Code)
}
if got := w.Body.Len(); got != 0 {
t.Errorf("body len = %d, want 0 for 304", got)
}
}
func TestHandleGetStream_BadUUID(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/tracks/not-a-uuid/stream", nil)
w := httptest.NewRecorder()
newLibraryRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestHandleGetStream_UnknownTrack(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
req := httptest.NewRequest(http.MethodGet,
"/api/tracks/00000000-0000-0000-0000-000000000001/stream", nil)
w := httptest.NewRecorder()
newLibraryRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d", w.Code)
}
var body errorBody
_ = json.Unmarshal(w.Body.Bytes(), &body)
if body.Error.Message != "track not found" {
t.Errorf("message = %q, want \"track not found\"", body.Error.Message)
}
}
func TestHandleGetStream_VanishedFile(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
artist := seedArtist(t, pool, "A")
album := seedAlbum(t, pool, artist.ID, "X", 0)
dir := t.TempDir()
// Insert a track whose file_path points somewhere we never create.
gone := filepath.Join(dir, "does-not-exist.mp3")
if _, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "gone", AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 1, FilePath: gone, FileSize: 1, FileFormat: "mp3",
}); err != nil {
t.Fatal(err)
}
// Look up the just-inserted track so we can address it by id.
tr, err := dbq.New(pool).GetTrackByPath(context.Background(), gone)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(tr.ID)+"/stream", nil)
w := httptest.NewRecorder()
newLibraryRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d", w.Code)
}
var body errorBody
_ = json.Unmarshal(w.Body.Bytes(), &body)
if body.Error.Message != "track file not found" {
t.Errorf("message = %q, want \"track file not found\"", body.Error.Message)
}
}