From a902055acf455e389daaf50a5d2a421dd29c84da Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Tue, 26 May 2026 10:56:48 +0200 Subject: [PATCH] fix(audiobooks): mount /public/session/{sid}/track/{idx} for ABS DirectPlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: the official ABS Android client (PlaybackSession.kt:194-200) on ABS server v2.22.0+ with DirectPlay builds the streaming URL as "$serverAddress/public/session/$id/track/$index" WITHOUT a token, ignoring audioTrack.contentUrl entirely. Silo reports version 2.35.0 and emits playMethod: 0 (DIRECTPLAY) but never mounted this route, so every play attempt 404'd silently — spinner forever. Add handlePublicTrack: look up the session by sid (ULID as capability, matches booklore-ng + continuum-plugin behavior), resolve the track by 1-based index against the session's media files, stream via playback.ServeDirectPlay (Range + HEAD supported). Mounted OUTSIDE bearerAuth at both /public/session/... and /abs/public/session/... . 6 unit tests cover serve / HEAD probe / unknown session / closed session / out-of-range / bad-index paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/audiobooks/abs/file_handler.go | 66 +++++++ .../abs/file_handler_public_track_test.go | 171 ++++++++++++++++++ internal/audiobooks/abs/handler.go | 9 + 3 files changed, 246 insertions(+) create mode 100644 internal/audiobooks/abs/file_handler_public_track_test.go diff --git a/internal/audiobooks/abs/file_handler.go b/internal/audiobooks/abs/file_handler.go index 36a630fd..e23a7a20 100644 --- a/internal/audiobooks/abs/file_handler.go +++ b/internal/audiobooks/abs/file_handler.go @@ -113,6 +113,72 @@ func (h *Handler) handleFileStream(w http.ResponseWriter, r *http.Request) { } } +// handlePublicTrack serves audio bytes for ONE track of a playback session. +// +// Real ABS Android client (v2.22.0+) builds the streaming URL as +// +// $serverAddress/public/session/{sessionId}/track/{audioTrack.index} +// +// WITHOUT appending any ?token=. The session ID itself is the capability: +// it's a 128-bit ULID, only known to the client that received it from +// /play, and tied server-side to (userID, contentID). This matches both +// the canonical continuum-plugin handler and booklore-ng's implementation. +// +// See android: PlaybackSession.kt:getContentUri (gte 2.22.0 + DirectPlay branch). +// +// Resolution: +// 1. Look up the session by sid via PlaybackSessionStore. +// 2. Load the ordered media-files list for the session's contentID. +// 3. files[idx-1] is the requested track (silo emits 1-based wireIndex). +// 4. Stream via playback.ServeDirectPlay (handles Range + HEAD). +// +// Mounted OUTSIDE bearerAuth: the client sends no Authorization header on +// this endpoint, and the session ID alone authorises access. +func (h *Handler) handlePublicTrack(w http.ResponseWriter, r *http.Request) { + sid := chi.URLParam(r, "sid") + idxStr := chi.URLParam(r, "idx") + if sid == "" || idxStr == "" { + http.Error(w, "sid and idx required", http.StatusBadRequest) + return + } + idx, err := strconv.Atoi(idxStr) + if err != nil || idx < 1 { + http.Error(w, "idx must be a positive integer", http.StatusBadRequest) + return + } + if h.deps.PlaybackSessionStore == nil { + http.Error(w, "session store not configured", http.StatusServiceUnavailable) + return + } + + sess, err := h.deps.PlaybackSessionStore.GetPlaybackSession(r.Context(), sid) + if err != nil { + http.Error(w, "session not found", http.StatusNotFound) + return + } + if sess.ClosedAt != nil { + http.Error(w, "session closed", http.StatusGone) + return + } + + files, err := h.deps.MediaStore.GetMediaFiles(r.Context(), sess.ContentID) + if err != nil || len(files) == 0 { + http.Error(w, "item files not found", http.StatusNotFound) + return + } + if idx > len(files) { + http.Error(w, "track index out of range", http.StatusNotFound) + return + } + mediaFile := files[idx-1] + + ext := strings.ToLower(filepath.Ext(mediaFile.FilePath)) + if ct := audioContentType(ext); ct != "" { + w.Header().Set("Content-Type", ct) + } + _ = playback.ServeDirectPlay(w, r, mediaFile.FilePath) +} + // audioContentType returns an audio MIME type for the given file extension // (including the dot). Returns empty string for unknown extensions, letting // ServeDirectPlay fall back to its own MIME detection. diff --git a/internal/audiobooks/abs/file_handler_public_track_test.go b/internal/audiobooks/abs/file_handler_public_track_test.go new file mode 100644 index 00000000..4e18ed0a --- /dev/null +++ b/internal/audiobooks/abs/file_handler_public_track_test.go @@ -0,0 +1,171 @@ +package abs + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// fakePlaybackSessionStore is an in-memory ABSPlaybackSessionStore for the +// public-track tests. Only Get is exercised; the other methods are no-ops. +type fakePlaybackSessionStore struct { + sessions map[string]ABSPlaybackSession +} + +func (f *fakePlaybackSessionStore) InsertPlaybackSession(_ context.Context, s ABSPlaybackSession) error { + if f.sessions == nil { + f.sessions = map[string]ABSPlaybackSession{} + } + f.sessions[s.ID] = s + return nil +} +func (f *fakePlaybackSessionStore) GetPlaybackSession(_ context.Context, id string) (ABSPlaybackSession, error) { + s, ok := f.sessions[id] + if !ok { + return ABSPlaybackSession{}, ErrNotFound + } + return s, nil +} +func (f *fakePlaybackSessionStore) SyncPlaybackSession(context.Context, string, float64, int) error { + return nil +} +func (f *fakePlaybackSessionStore) ClosePlaybackSession(context.Context, string) error { return nil } + +// filesMediaStore returns a fixed slice of MediaFile entries for the +// configured contentID, satisfying the MediaStore interface for the +// public-track tests. Unconfigured methods inherit no-op behavior from +// noopMediaStore via embedding. +type filesMediaStore struct { + noopMediaStore + contentID string + files []*models.MediaFile +} + +func (f *filesMediaStore) GetMediaFiles(_ context.Context, contentID string) ([]*models.MediaFile, error) { + if contentID != f.contentID { + return nil, nil + } + return f.files, nil +} + +// makeTempAudio writes minimal bytes to a .mp3 file in t.TempDir() and +// returns the path. ServeDirectPlay only needs the file to exist and be +// readable; content correctness is not asserted by these tests. +func makeTempAudio(t *testing.T) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "track.mp3") + if err := os.WriteFile(p, []byte("\xff\xfb\x00\x00audio-bytes"), 0o644); err != nil { + t.Fatalf("write temp audio: %v", err) + } + return p +} + +// newPublicTrackHandler builds a Handler with the minimum deps to serve +// /public/session/{sid}/track/{idx}: a seeded session store + a media store +// holding ONE audio file for that session's contentID. +func newPublicTrackHandler(t *testing.T, sid, contentID string, closed bool) (*Handler, string) { + t.Helper() + audioPath := makeTempAudio(t) + sessStore := &fakePlaybackSessionStore{} + sess := ABSPlaybackSession{ID: sid, UserID: "u1", ContentID: contentID} + if closed { + now := time.Now() + sess.ClosedAt = &now + } + _ = sessStore.InsertPlaybackSession(context.Background(), sess) + + mediaStore := &filesMediaStore{ + contentID: contentID, + files: []*models.MediaFile{{ID: 1, FilePath: audioPath}}, + } + h := New(Dependencies{ + MediaStore: mediaStore, + PlaybackSessionStore: sessStore, + }) + return h, audioPath +} + +// dispatchTrack invokes handlePublicTrack with the URL params chi would +// normally inject from the route. Mirrors how chi.URLParam reads from the +// request context — without this the handler can't see {sid}/{idx}. +func dispatchTrack(h *Handler, method, sid, idx string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, "/public/session/"+sid+"/track/"+idx, nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("sid", sid) + rctx.URLParams.Add("idx", idx) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + rec := httptest.NewRecorder() + h.handlePublicTrack(rec, req) + return rec +} + +func TestHandlePublicTrack_ServesBytesForValidSession(t *testing.T) { + h, _ := newPublicTrackHandler(t, "sid-1", "book-1", false) + rec := dispatchTrack(h, http.MethodGet, "sid-1", "1") + if rec.Code != http.StatusOK && rec.Code != http.StatusPartialContent { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if rec.Body.Len() == 0 { + t.Errorf("response body empty; expected audio bytes") + } + if got := rec.Header().Get("Content-Type"); got != "audio/mpeg" { + t.Errorf("Content-Type = %q, want audio/mpeg", got) + } +} + +// TestHandlePublicTrack_HeadProbe covers the iOS/Android HEAD pre-flight +// some players issue before the GET. http.ServeContent returns headers +// without a body for HEAD; the handler must not 404. +func TestHandlePublicTrack_HeadProbe(t *testing.T) { + h, _ := newPublicTrackHandler(t, "sid-1", "book-1", false) + rec := dispatchTrack(h, http.MethodHead, "sid-1", "1") + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if rec.Body.Len() != 0 { + t.Errorf("HEAD response should have empty body; got %d bytes", rec.Body.Len()) + } +} + +func TestHandlePublicTrack_UnknownSession404(t *testing.T) { + h, _ := newPublicTrackHandler(t, "sid-1", "book-1", false) + rec := dispatchTrack(h, http.MethodGet, "sid-does-not-exist", "1") + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } +} + +func TestHandlePublicTrack_ClosedSession410(t *testing.T) { + h, _ := newPublicTrackHandler(t, "sid-1", "book-1", true) + rec := dispatchTrack(h, http.MethodGet, "sid-1", "1") + if rec.Code != http.StatusGone { + t.Errorf("status = %d, want 410", rec.Code) + } +} + +func TestHandlePublicTrack_IndexOutOfRange404(t *testing.T) { + h, _ := newPublicTrackHandler(t, "sid-1", "book-1", false) + rec := dispatchTrack(h, http.MethodGet, "sid-1", "5") + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", rec.Code) + } +} + +func TestHandlePublicTrack_BadIndex400(t *testing.T) { + h, _ := newPublicTrackHandler(t, "sid-1", "book-1", false) + for _, bad := range []string{"0", "-1", "abc"} { + rec := dispatchTrack(h, http.MethodGet, "sid-1", bad) + if rec.Code != http.StatusBadRequest { + t.Errorf("idx=%q: status = %d, want 400", bad, rec.Code) + } + } +} diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index 808e45cf..ece7bdfe 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -268,6 +268,15 @@ func (h *Handler) mountRoutes(r chi.Router) { r.Get(prefix+"/authors/{id}/image", h.handleAuthorImage) } + // Session-scoped audio streaming (ABS v2.22.0+ DirectPlay). The Android + // and iOS clients call this WITHOUT a bearer token — the session ID is + // the capability. Mounted at both /public/session and /abs/public/session + // for compatibility with clients that pin either prefix. + for _, prefix := range []string{"", "/abs"} { + r.Get(prefix+"/public/session/{sid}/track/{idx}", h.handlePublicTrack) + r.Head(prefix+"/public/session/{sid}/track/{idx}", h.handlePublicTrack) + } + // Stage 3: playback session + file routes, registered under both the // legacy /abs/api prefix and the canonical /api prefix that the official // ABS mobile client builds against (no /abs prefix at server root).