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:
co-authored by
Claude Opus 4.7
parent
64bb15de84
commit
a902055acf
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user