fix(audiobooks): mount /public/session/{sid}/track/{idx} for ABS DirectPlay

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) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-26 10:56:48 +02:00
co-authored by Claude Opus 4.7
parent 64bb15de84
commit a902055acf
3 changed files with 246 additions and 0 deletions
+66
View File
@@ -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.
@@ -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)
}
}
}
+9
View File
@@ -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).