From 3656461b20d896de690e867d41c1148183f5e631 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 21 Apr 2026 22:44:48 -0400 Subject: [PATCH] feat(api): GET /api/tracks/{id}/stream with Range support Co-Authored-By: Claude Opus 4.7 --- internal/api/library_test.go | 1 + internal/api/media.go | 39 ++++++++ internal/api/media_test.go | 174 +++++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+) diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 961eae01..21a8a9b0 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -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 } diff --git a/internal/api/media.go b/internal/api/media.go index a0045280..8f496d80 100644 --- a/internal/api/media.go +++ b/internal/api/media.go @@ -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) +} diff --git a/internal/api/media_test.go b/internal/api/media_test.go index 39d286a5..8be7b433 100644 --- a/internal/api/media_test.go +++ b/internal/api/media_test.go @@ -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) + } +}