Files
silo-server/internal/audiobooks/abs/handler.go
T
CoffeeKnyte 09de4f08ec feat(downloads): monitor in-flight download pours in memory
Six routes poured full media with no monitor record and no byte measurement:
the two native download routes, the Jellyfin-compat download, both ABS file
variants, and the public ABS RSS feed file. They were the last invisible bytes
on the server.

- internal/transfers: a process-local, in-memory registry of active pours.
  Bounded (10k entries) with rate-limited "full" warnings, all request-derived
  strings normalized and length-clamped (ABS and jellycompat carry no bounded
  client name, so those fields are header-derived and untrusted), overflow-safe
  byte accumulation, deterministic snapshots, nil-safe throughout. No I/O on any
  path, and no persistence: a pour dies with the process, so durable rows would
  only need reaping after a crash.
- Reuses the existing playback.SessionMeteredWriter via ServedBytesRecorder
  rather than adding a second writer — that writer is where the sendfile
  (ReadFrom) and kill-cut (Unwrap) hazards live and both have regressed before.
- Deliberately NOT plumbed through streammonitor/streamenforcer, which are
  untouched. Downloads stay off the live-stream path by construction: there is
  no type, field or collection through which one can reach the enforcer, so a
  download can never be counted against max_streams or trimmed as an over-cap
  stream.
- Admin visibility is a sibling `transfers` array on the existing
  /admin/nodes/sessions response; the `sessions` array is byte-for-byte
  unchanged. Transfers appear only in the unfiltered listing, since a node_id
  filter targets an edge and these are process-local.
- Per-pour ids are unique, never the download id: concurrent and repeated
  GET/Range requests against one download row are legitimate and would collide.
  The id is minted in the handler (where the revocation watcher is armed) and
  the registry entry is opened in the service only after file/artifact
  resolution succeeds, so a failed auth or lookup never registers a transfer.
- Defer order is an invariant at every call site: End is registered before the
  meter's Close so LIFO flushes the tail first. Reversed, a final sub-1MiB flush
  lands on an unknown id and is silently lost. Pinned by a test.

No schema change and no migration: downloads.bytes_sent keeps its documented
meaning (a lifecycle marker set to file_size on completion, not a live counter)
and is untouched.

Phase 2 — killing a single download, and a standing per-user download block —
is deliberately deferred. A user revocation already cuts in-flight download
pours; it is a cutoff, so it does not refuse new ones. The unique per-pour id
exists so phase 2 only changes "" to that id at each WatchAndCut site.

Part of the stream monitoring & kill-switch epic.
2026-07-29 16:42:37 +00:00

802 lines
36 KiB
Go

// Package abs implements the Audiobookshelf-mobile-app compatibility surface.
// It mints self-contained JWTs signed with a per-deployment secret and serves
// the /abs/api/* and /abs/public/* routes, as well as the canonical
// root-level paths real ABS clients build against (e.g. /login, /api/items).
//
// Stage 1 lands the package skeleton: Handler struct, interface stubs for
// silo-side dependencies, and an empty Mount() method. Real route handlers
// are added in subsequent stages (auth, file serving, progress, browse).
package abs
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/streamrevoke"
"github.com/Silo-Server/silo-server/internal/transfers"
)
// ---------------------------------------------------------------------------
// Dependency interfaces
// ---------------------------------------------------------------------------
// AudiobookLibrary is the narrow library view the ABS handlers expose.
// The production adapter (Stage 7) builds these from media_folders WHERE
// type = 'audiobooks'.
type AudiobookLibrary struct {
ID int64
Name string
Type string // always "audiobooks" for this surface
}
// MediaStore is the slice of silo's catalog the ABS handler reads.
// Real impl: catalog.ItemRepository + scanner.FileRepository wrapped in
// a small adapter struct added in a later stage.
type MediaStore interface {
GetAudiobookByID(ctx context.Context, contentID string, access catalog.AccessFilter) (*models.MediaItem, error)
// GetAudiobooksByIDs batch-fetches audiobooks by content_id (people + series
// hydrated once), keyed by content_id, for list/shelf handlers.
GetAudiobooksByIDs(ctx context.Context, contentIDs []string, access catalog.AccessFilter) (map[string]*models.MediaItem, error)
// ListAudiobooks returns a page of audiobooks. When libraryID is non-zero
// it filters to items in that media_folder; 0 means all audiobook items.
// filter optionally pushes an authors/series/narrators predicate into the
// query (Filter{} for none) so per-author syncs avoid a full-library scan.
ListAudiobooks(ctx context.Context, libraryID int64, limit, offset int, access catalog.AccessFilter, filter Filter) ([]*models.MediaItem, int, error)
GetMediaFiles(ctx context.Context, contentID string, access catalog.AccessFilter) ([]*models.MediaFile, error)
// GetMediaFileByID fetches a single media file by its integer PK.
// Used by the ABS file-streaming handler when a caller supplies a
// raw file ID instead of an ino.
GetMediaFileByID(ctx context.Context, fileID int) (*models.MediaFile, error)
// ListAudiobookLibraries returns media_folder rows with type='audiobooks'.
ListAudiobookLibraries(ctx context.Context, access catalog.AccessFilter) ([]AudiobookLibrary, error)
// SearchAudiobooks does a fuzzy title/author/narrator match for the ABS
// /libraries/{id}/search endpoint. Hydrates People so the mapper has
// author/narrator names.
SearchAudiobooks(ctx context.Context, libraryID int64, query string, limit int, access catalog.AccessFilter) ([]*models.MediaItem, error)
// ListContinueListening returns books that the given user has progress
// on but hasn't finished — feeds the Home tab's continue shelf.
ListContinueListening(ctx context.Context, userID, profileID string, libraryID int64, limit int, access catalog.AccessFilter) ([]*models.MediaItem, error)
// ListRecentlyAdded returns the most recently added audiobooks for the
// Home tab's recently-added shelf.
ListRecentlyAdded(ctx context.Context, libraryID int64, limit int, access catalog.AccessFilter) ([]*models.MediaItem, error)
// ListDiscover returns a randomized sampling of audiobooks for the
// Home tab's discover shelf (helps new users browse the library).
ListDiscover(ctx context.Context, libraryID int64, limit int, access catalog.AccessFilter) ([]*models.MediaItem, error)
// ListLibraryAuthors returns one page of distinct audiobook authors (from a
// precomputed materialized view) plus the total author count. sortBy is one
// of "name" (default), "addedAt", or "numBooks"; limit<=0 returns all.
ListLibraryAuthors(ctx context.Context, libraryID int64, limit, offset int, sortBy string, sortDesc bool, access catalog.AccessFilter) ([]AuthorSummary, int, error)
// ListLibrarySeries returns one SQL-paginated page of distinct series (from
// audiobook_series) in the library plus the total series count. limit<=0
// returns all.
ListLibrarySeries(ctx context.Context, libraryID int64, limit, offset int, access catalog.AccessFilter) ([]SeriesSummary, int, error)
// GetAuthorByID returns the author with the given people.id plus
// their audiobook list, sorted by title. Returns ErrNotFound when
// no people row matches.
GetAuthorByID(ctx context.Context, authorID string, access catalog.AccessFilter) (Author, error)
// GetSeriesByName returns the canonical series (case-insensitive
// match on audiobook_series.series_name) with its books ordered
// by series_index ASC (NULLS LAST), title fallback. Returns
// ErrNotFound when no rows match.
GetSeriesByName(ctx context.Context, seriesName string, access catalog.AccessFilter) (Series, error)
}
// AuthorSummary is an aggregated author entry for /libraries/{id}/authors.
type AuthorSummary struct {
ID string
Name string
NumBooks int
// HasPhoto reports whether the author's people row carries a photo, so
// list responses can emit a non-null imagePath (the client's cue to
// fetch /api/authors/{id}/image).
HasPhoto bool
}
// SeriesSummary is an aggregated series entry for /libraries/{id}/series.
//
// Books carries up to ~4 cover-preview entries for the LazySeriesCard
// GroupCover stack — the ABS mobile client reads
// `series.books[i].media.coverPath` to render each cover, and a card
// with no books renders only the series name as a fallback.
type SeriesSummary struct {
ID string
Name string
NumBooks int
Books []SeriesBookPreview
}
// SeriesBookPreview is a single book id+title pair returned alongside
// each SeriesSummary. The /libraries/{id}/series handler expands these
// into full minified LibraryItem entries (with cover URLs) on the wire.
type SeriesBookPreview struct {
ContentID string
Title string
UpdatedAt time.Time
}
// Author is the detail-shape author with embedded books list.
type Author struct {
ID string
Name string
PosterPath string // resolved via CoverResolver on emit
Books []*models.MediaItem
}
// Series is the detail-shape series with books ordered by series_index.
type Series struct {
ID string // lowercased series_name
Name string // canonical series_name
Books []*models.MediaItem
}
// TokenStore persists and validates the ABS JWT JTIs that back the
// revocable-token surface (login, refresh, logout, bearerAuth).
// Real impl: a thin repo over the abs_tokens table added in Stage 2.
type TokenStore interface {
// InsertToken persists a newly minted JTI.
InsertToken(ctx context.Context, tok ABSToken) error
// GetTokenByJTI looks up a token by its JTI; returns ErrNotFound if absent.
GetTokenByJTI(ctx context.Context, jti string) (ABSToken, error)
// RevokeTokenByJTI marks a JTI as revoked (sets revoked_at).
RevokeTokenByJTI(ctx context.Context, jti string) error
// RevokeTokenIfActive atomically marks an unrevoked token as revoked and
// returns its previous row. Returns ErrNotFound when the token is absent or
// was already revoked.
RevokeTokenIfActive(ctx context.Context, jti string) (ABSToken, error)
// RevokeTokensForPrincipal revokes every active access/refresh token for a
// user profile. Logout uses this to invalidate refresh tokens as well as the
// presented access token.
RevokeTokensForPrincipal(ctx context.Context, userID, profileID string) error
// TouchToken extends last_seen_at for active-session bookkeeping.
TouchToken(ctx context.Context, jti string) error
}
// ABSToken is the in-memory representation of a persisted JTI row.
type ABSToken struct {
ID string
UserID string
ProfileID string
Type string
JTI string
ExpiresAt time.Time
RevokedAt *time.Time
}
// ProfileCredentialValidator validates a (username, password) pair against
// silo's auth backend. Implemented by an adapter over internal/auth in a
// later stage.
type ProfileCredentialValidator interface {
Validate(ctx context.Context, username, password string) (userID string, profileID string, displayName string, err error)
}
// AccessResolver resolves the ABS-authenticated user/profile into the same
// effective catalog access filter used by silo's native API.
type AccessResolver interface {
ResolveABSAccess(ctx context.Context, userID, profileID string) (catalog.AccessFilter, error)
}
// EventPublisher delivers a realtime event to Socket.io clients. May be nil;
// handlers guard with publish/broadcast nil-safe wrappers.
type EventPublisher interface {
Publish(userID, event string, payload any)
Broadcast(event string, payload any)
}
// SocketIOServer exposes the Socket.io HTTP handler. The concrete
// implementation lives in internal/audiobooks/abssocket. Keeping the
// interface here avoids a circular import: handler.go uses it, abssocket
// imports abs for ParseToken/EventPublisher, and the wiring is done by the
// caller (service.go or main.go) that imports both packages.
type SocketIOServer interface {
Handler() http.Handler
}
// Recommender powers /items/{id}/similar. nil → route returns an empty list.
type Recommender interface {
Similar(ctx context.Context, contentID string, limit int) ([]string, error)
}
// ---------------------------------------------------------------------------
// Config provider
// ---------------------------------------------------------------------------
// ConfigProvider supplies runtime config values the ABS handler needs.
// Keeps the handler decoupled from any particular settings-store shape.
type ConfigProvider interface {
// JWTSecret returns the HMAC-SHA256 signing secret for ABS JWTs.
JWTSecret(ctx context.Context) ([]byte, error)
// AccessTTL / RefreshTTL are the default token lifetimes; zero means
// "use built-in default (24 h / 30 d)".
AccessTTL(ctx context.Context) (time.Duration, error)
RefreshTTL(ctx context.Context) (time.Duration, error)
// StandaloneLoginEnabled reports whether body-creds login is permitted
// (i.e., operator has not disabled it in settings).
StandaloneLoginEnabled(ctx context.Context) (bool, error)
}
// ---------------------------------------------------------------------------
// Dependencies + Handler
// ---------------------------------------------------------------------------
// Dependencies bundles everything the Handler needs at construction time.
type Dependencies struct {
MediaStore MediaStore
TokenStore TokenStore
CredValidator ProfileCredentialValidator
AccessResolver AccessResolver
// UsernameResolver returns the display username for an ABS principal
// (userID, profileID) without re-authenticating. Optional; GET /me falls
// back to the userID when this is nil or returns "". Login gets the
// display name from the credential validator, but /me only has the token
// claims, so it needs this to show the real username instead of the id.
UsernameResolver func(ctx context.Context, userID, profileID string) string
Config ConfigProvider
Publisher EventPublisher // may be nil
Recommender Recommender // may be nil
LoginLimiter *LoginLimiter // may be nil — one is created if absent
// InstallID returns the current plugin install ID for building
// host-proxy-routable URLs. Defaults to "silo.audiobooks" when nil.
InstallID func() string
// ProgressStore provides access to user_watch_progress for ABS
// progress endpoints. May be nil; handlers degrade gracefully.
ProgressStore ProgressStore
// PlaybackSessionStore persists abs_playback_sessions rows
// (migration 143) for /session/{sid}/sync and /session/{sid}/close.
// May be nil; handlers degrade gracefully.
PlaybackSessionStore ABSPlaybackSessionStore
// BookmarkStore persists ABS bookmark rows (migration 148) for the
// POST/PATCH/DELETE /me/item/{itemId}/bookmark endpoints. May be
// nil; handlers respond 503 when unset.
BookmarkStore BookmarkStore
// CollectionStore persists ABS user-collection rows (migrations 149 + 150).
// May be nil; handlers respond 503 when unset.
CollectionStore CollectionStore
// PlaylistStore persists ABS playlist rows (migrations 151 + 152).
// May be nil; handlers respond 503 when unset.
PlaylistStore PlaylistStore
// SmartCollectionStore persists user_personal_collections rows with
// collection_type='smart' (migration 156 unified the old
// abs_smart_collections table into the canonical store).
// May be nil; handlers respond 503 when unset.
SmartCollectionStore SmartCollectionStore
// RSSFeedStore persists abs_rss_feeds rows (migration 155).
// May be nil; handlers respond 503 when unset.
RSSFeedStore RSSFeedStore
// SocketIO is the Socket.io server mounted at /abs/socket.io/. May be nil;
// the route is only registered when a non-nil value is supplied.
SocketIO SocketIOServer
// NativeSessions mirrors ABS playback into Silo's native playback session
// manager so shared live-session views, limits, and stale-session cleanup
// see Audiobookshelf-compatible clients. May be nil; ABS playback still
// functions, but admin live-session visibility is unavailable.
NativeSessions PlaybackSessionManager
// Revocation makes ABS byte-serving routes obey the shared stream kill
// switch. Nil preserves compatibility for isolated tests.
Revocation *streamrevoke.Store
// Transfers tracks file pours separately from native playback sessions.
Transfers *transfers.Registry
// NativeSessionSyncer flushes native session-manager state into the shared
// admin live-session table after ABS play/sync/close events.
NativeSessionSyncer PlaybackSessionSyncer
// CoverResolver translates a raw silo poster path (e.g.
// "local/audiobooks/.../original.webp") into a fully-qualified URL
// the ABS client can fetch. Optional; when nil, /api/items/{id}/cover
// 404s rather than redirecting to an unreachable relative path.
CoverResolver func(ctx context.Context, path, variant string) string
}
// Handler wires the /abs/api/* and canonical ABS-client paths.
type Handler struct {
deps Dependencies
}
// New constructs an ABS Handler. Sensible defaults are applied for optional
// fields (LoginLimiter, InstallID).
//
// MediaStore is required: many handlers (libraries, items, me, play) deref
// it unconditionally on the request hot path, and a nil store would panic
// the first time a real request hits them. Fail fast at construction so
// misconfigured deployments break at startup rather than silently passing
// /login and crashing on the next request.
func New(deps Dependencies) *Handler {
if deps.MediaStore == nil {
panic("abs.New: MediaStore is required")
}
if deps.LoginLimiter == nil {
deps.LoginLimiter = NewLoginLimiter()
}
if deps.InstallID == nil {
deps.InstallID = func() string { return "silo.audiobooks" }
}
return &Handler{deps: deps}
}
// ---------------------------------------------------------------------------
// Mount
// ---------------------------------------------------------------------------
// Mount registers the ABS-compatible routes on r. Stage 1 registers an empty
// /abs group with the access-log middleware attached; subsequent stages add
// real route handlers.
//
// The dual-mount design (routes at both /abs/api/* and /* roots) is preserved
// here so stage-by-stage handlers land in the right places without needing to
// revisit Mount later.
func (h *Handler) Mount(parent chi.Router) {
parent.Group(func(r chi.Router) {
r.Use(h.accessLog)
h.mountRoutes(r)
})
}
func (h *Handler) mountRoutes(r chi.Router) {
// Discovery + auth endpoints: real ABS exposes these at server ROOT
// (no /api or /abs/api prefix). Mobile clients do `${addr}/ping`,
// `${addr}/login`, etc. Designed to be mounted on a dedicated listener
// so the routes don't collide with silo's SPA catch-all.
for _, prefix := range []string{"", "/abs/api"} {
r.Get(prefix+"/ping", h.handleABSPing)
r.Get(prefix+"/healthcheck", h.handleABSPing) // same body as /ping
r.Get(prefix+"/init", h.handleABSInit)
r.Get(prefix+"/status", h.handleABSStatus)
}
// Stage 2: login (body credentials). Real ABS serves /login at root, but
// clients differ on the prefix — some POST /api/login or /abs/api/login.
// The rest of the authenticated surface is mounted under both /api and
// /abs/api, so mount login+refresh under the same set; a client posting
// /api/login otherwise 404s and surfaces a generic "unknown error".
for _, prefix := range []string{"", "/api", "/abs/api"} {
r.Post(prefix+"/login", h.handleLogin)
// Token rotation — mobile clients call this every ~22h to avoid the
// 24h access-token interactive re-login trap.
r.Post(prefix+"/auth/refresh", h.handleRefresh)
}
// Logout is mounted OUTSIDE bearerAuth so an expired-access client can
// still sign out (the primary "sign out" UX moment). The handler parses
// the bearer locally, revokes the JTI if parseable, and always returns
// 204 — matches the canonical continuum-plugin behavior.
r.Post("/logout", h.handleLogout)
r.Post("/api/logout", h.handleLogout)
r.Post("/abs/api/logout", h.handleLogout)
r.Post("/abs/api/auth/logout", h.handleLogout) // legacy path
// Unauthenticated cover + author-image routes. Real ABS serves covers
// without auth (getDoesServerImagesRequireToken returns false for our
// version), so mounting these outside bearerAuth avoids 401s.
for _, prefix := range []string{"/abs/api", "/api"} {
r.Get(prefix+"/items/{id}/cover", h.handleItemCover)
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)
}
// Public RSS feed routes — slug is the capability token, no auth.
r.Get("/feed/{slug}.xml", h.handlePublicFeed)
r.Get("/feed/{slug}", h.handlePublicFeed)
r.Get("/feed/{slug}/file/{ino}", h.handlePublicFeedFile)
// Server discovery — unauthenticated. Mounted at both /api and the
// canonical root so curl-style network probes, the official ABS app's
// connect-server flow, and AudioBooth's saved-server liveness check
// all land on the same response.
for _, prefix := range []string{"/abs", "/api"} {
r.Get(prefix+"/ping", h.handlePing)
r.Get(prefix+"/healthcheck", h.handleHealthcheck)
r.Get(prefix+"/init", h.handleInit)
r.Get(prefix+"/auth-settings", h.handleAuthSettings)
}
// 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).
r.Group(func(r chi.Router) {
r.Use(h.bearerAuth)
for _, prefix := range []string{"/abs/api", "/api"} {
// POST /api/items/{libraryItemId}/play — start a play session,
// get back a stream URL + ABS-shaped manifest.
r.Post(prefix+"/items/{libraryItemId}/play", h.handlePlayStart)
// GET /api/items/{libraryItemId}/file/{ino} — stream a specific audio file.
// /download variant is the same handler; Content-Disposition is set when
// the path ends in /download.
r.Get(prefix+"/items/{libraryItemId}/file/{ino}", h.handleFileStream)
r.Get(prefix+"/items/{libraryItemId}/file/{ino}/download", h.handleFileStream)
}
})
// Stage 4: progress + session tracking — requires bearerAuth.
r.Group(func(r chi.Router) {
r.Use(h.bearerAuth)
for _, prefix := range []string{"/abs/api", "/api"} {
// GET /me/progress — all audiobook progress for the caller
r.Get(prefix+"/me/progress", h.handleGetMyProgress)
// GET /me/progress/{id} — progress for one item
r.Get(prefix+"/me/progress/{libraryItemId}", h.handleGetItemProgress)
// POST /me/progress/{id} — set / update progress (ABS PATCH semantics)
r.Post(prefix+"/me/progress/{libraryItemId}", h.handleSetItemProgress)
// PATCH alias — AudioBooth and the canonical ABS server use
// PATCH for the same write; route both methods to the handler.
r.Patch(prefix+"/me/progress/{libraryItemId}", h.handleSetItemProgress)
// DELETE /me/progress/{id} — clear progress (Reset Progress)
r.Delete(prefix+"/me/progress/{libraryItemId}", h.handleDeleteItemProgress)
// PATCH /me/progress/{id}/{episodeId} — podcast episode
// progress; audiobook-only catalog, so this is a stub.
r.Patch(prefix+"/me/progress/{libraryItemId}/{episodeId}", h.handleSetEpisodeProgress)
// POST /session/{sid}/sync — real ABS heartbeat path
// (SessionController.sync). The official ABS mobile/web clients
// POST here; missing it means playback progress never syncs.
r.Post(prefix+"/session/{sid}/sync", h.handleSessionSync)
// PATCH /session/{sid} — silo-native heartbeat alias
// (kept additive for silo's own clients).
r.Patch(prefix+"/session/{sid}", h.handleSessionSync)
// POST /session/{sid}/close — finalise the play session
r.Post(prefix+"/session/{sid}/close", h.handleSessionClose)
// POST /session/local — sync one offline-recorded session
r.Post(prefix+"/session/local", h.handleSyncLocalSession)
// POST /session/local-all — batch-sync offline-recorded sessions
r.Post(prefix+"/session/local-all", h.handleSyncLocalSessions)
// Bookmarks — POST/PATCH both upsert; DELETE is idempotent.
r.Post(prefix+"/me/item/{itemId}/bookmark", h.handleUpsertBookmark("bookmark_created"))
r.Patch(prefix+"/me/item/{itemId}/bookmark", h.handleUpsertBookmark("bookmark_updated"))
r.Delete(prefix+"/me/item/{itemId}/bookmark/{time}", h.handleDeleteBookmark)
// Collections — owner-gated CRUD with cross-user public reads.
r.Get(prefix+"/collections", h.handleListCollections)
// Per-library collections list — bookshelf "Collections" tab
// hits this. Paged envelope with full-shape entries (books[]
// included) so the cover stack renders.
r.Get(prefix+"/libraries/{libraryId}/collections", h.handleListLibraryCollections)
r.Post(prefix+"/collections", h.handleCreateCollection)
r.Get(prefix+"/collections/{id}", h.handleGetCollection)
r.Patch(prefix+"/collections/{id}", h.handleUpdateCollection)
r.Delete(prefix+"/collections/{id}", h.handleDeleteCollection)
r.Post(prefix+"/collections/{id}/book/{bookId}", h.handleAddCollectionBook)
r.Delete(prefix+"/collections/{id}/book/{bookId}", h.handleRemoveCollectionBook)
// Playlists — owner-gated CRUD with cross-user public reads,
// realtime events on every mutation, batch endpoints.
r.Get(prefix+"/playlists", h.handleListPlaylists)
// Per-library playlist list — mobile create-playlist modal
// loads from here before opening the form. Emits
// `{results: [...]}` with full-shape entries.
r.Get(prefix+"/libraries/{libraryId}/playlists", h.handleListLibraryPlaylists)
r.Post(prefix+"/playlists", h.handleCreatePlaylist)
r.Get(prefix+"/playlists/{id}", h.handleGetPlaylist)
r.Patch(prefix+"/playlists/{id}", h.handleUpdatePlaylist)
r.Delete(prefix+"/playlists/{id}", h.handleDeletePlaylist)
r.Post(prefix+"/playlists/{id}/item", h.handleAddPlaylistItem)
r.Post(prefix+"/playlists/{id}/batch/add", h.handleBatchAddPlaylistItems)
r.Post(prefix+"/playlists/{id}/batch/remove", h.handleBatchRemovePlaylistItems)
r.Delete(prefix+"/playlists/{id}/item/{libraryItemId}", h.handleRemovePlaylistItem)
r.Delete(prefix+"/playlists/{id}/item/{libraryItemId}/{episodeId}", h.handleRemovePlaylistEpisode)
// Smart collections — rule-based dynamic groupings.
r.Get(prefix+"/me/smart-collections", h.handleListSmartCollections)
r.Post(prefix+"/me/smart-collections", h.handleCreateSmartCollection)
r.Get(prefix+"/me/smart-collections/{id}", h.handleGetSmartCollection)
r.Get(prefix+"/me/smart-collections/{id}/items", h.handleSmartCollectionItems)
r.Patch(prefix+"/me/smart-collections/{id}", h.handleUpdateSmartCollection)
r.Delete(prefix+"/me/smart-collections/{id}", h.handleDeleteSmartCollection)
// Phase 1 close-out: listening stats / author+series / continue / RSS auth.
r.Get(prefix+"/me/listening-stats", h.handleListeningStats)
r.Get(prefix+"/me/listening-sessions", h.handleListeningSessions)
r.Get(prefix+"/me/listening-sessions/{sid}", h.handleListeningSessionDetail)
r.Get(prefix+"/authors/{id}", h.handleAuthorDetail)
r.Get(prefix+"/series/{id}", h.handleSeriesDetail)
r.Get(prefix+"/me/progress/{itemId}/remove-from-continue-listening", h.handleRemoveFromContinueListening)
r.Get(prefix+"/me/progress/{itemId}/readd-to-continue-listening", h.handleReaddToContinueListening)
r.Get(prefix+"/feeds", h.handleListRSSFeeds)
r.Post(prefix+"/feeds/item/{itemId}/open", h.handleOpenItemFeed)
r.Post(prefix+"/feeds/{id}/close", h.handleCloseFeed)
// Year-in-review stats — AudioBooth's "Year Stats" widget on the
// profile screen. Synthesized from AggregateStats today.
r.Get(prefix+"/me/stats/year/{year}", h.handleYearStats)
// Ebook surface — stubs until the ebook scanner lands.
// Mobile clients call these but degrade cleanly on empty/404.
r.Get(prefix+"/items/{id}/ebook/{fileid}", h.handleEbookFile)
r.Patch(prefix+"/items/{id}/ebook/{fileid}/status", h.handleEbookStatus)
// E-reader devices + ebook email delivery — empty list / 503
// until SMTP integration is wired.
r.Get(prefix+"/me/ereader-devices", h.handleListEreaderDevices)
r.Post(prefix+"/emails/send-ebook-to-device", h.handleSendEbookToDevice)
// Podcast stubs — audiobook-only catalog in v1. Endpoints
// return empty-but-well-formed shapes so the mobile UI doesn't
// crash on the podcast surfaces.
r.Post(prefix+"/podcasts/feed", h.handlePodcastFeed)
r.Post(prefix+"/items/{libraryItemId}/play/{episodeId}", h.handlePlayEpisode)
r.Get(prefix+"/libraries/{libraryId}/recent-episodes", h.handleRecentEpisodes)
r.Get(prefix+"/search/podcast", h.handleSearchPodcast)
}
})
// Stage 5: browse routes (libraries, items, item detail, me, similar,
// continue-listening) + author/series/search/personalized stubs.
// Requires bearerAuth.
r.Group(func(r chi.Router) {
r.Use(h.bearerAuth)
for _, prefix := range []string{"/abs/api", "/api"} {
// Current user object.
r.Get(prefix+"/me", h.handleMe)
// Real-ABS /authorize: validates the bearer and re-mints the
// /me envelope so the client can resume without retyping creds.
r.Post(prefix+"/authorize", h.handleABSAuthorize)
// Continue Listening shelf.
r.Get(prefix+"/me/items-in-progress", h.handleItemsInProgress)
// Library list + detail.
r.Get(prefix+"/libraries", h.handleLibraries)
r.Get(prefix+"/libraries/{libraryId}", h.handleLibraryDetail)
// Browse items in a library.
r.Get(prefix+"/libraries/{libraryId}/items", h.handleLibraryItems)
// Author / series / search / personalized — stubbed.
r.Get(prefix+"/libraries/{libraryId}/authors", h.handleLibraryAuthors)
r.Get(prefix+"/libraries/{libraryId}/series", h.handleLibrarySeries)
r.Get(prefix+"/libraries/{libraryId}/search", h.handleLibrarySearch)
r.Get(prefix+"/libraries/{libraryId}/personalized", h.handlePersonalized)
// Single item detail.
r.Get(prefix+"/items/{id}", h.handleItem)
// Similar items (optional Recommender; empty list when nil).
r.Get(prefix+"/items/{id}/similar", h.handleSimilarItems)
}
})
// Stage 6: Socket.io realtime endpoint.
if h.deps.SocketIO != nil {
r.Mount("/socket.io", h.deps.SocketIO.Handler())
r.Mount("/abs/socket.io", h.deps.SocketIO.Handler())
}
// TODO: social / collection routes (bookmarks, smart-collections,
// collections, playlists, RSS feeds, author/series detail, listening stats)
}
// ---------------------------------------------------------------------------
// Auth context helpers (used by bearerAuth middleware + handlers)
// ---------------------------------------------------------------------------
// ctxKey is the unexported ABS auth context key.
type ctxKey struct{}
// ctxAuth carries the decoded ABS JWT claims for the lifetime of a request.
type ctxAuth struct {
UserID string
ProfileID string
JTI string
Token string // raw bearer token
IssuedAt time.Time
}
// absAuthFrom extracts ABS auth from the request context. Returns (zero, false)
// when bearerAuth middleware hasn't run (unauthenticated routes).
func absAuthFrom(r *http.Request) (ctxAuth, bool) {
a, ok := r.Context().Value(ctxKey{}).(ctxAuth)
return a, ok
}
func (h *Handler) accessFilterForAuth(ctx context.Context, a ctxAuth) (catalog.AccessFilter, error) {
if h.deps.AccessResolver != nil {
return h.deps.AccessResolver.ResolveABSAccess(ctx, a.UserID, a.ProfileID)
}
filter := catalog.AccessFilter{ProfileID: a.ProfileID}
if uid, err := strconv.Atoi(a.UserID); err == nil {
filter.UserID = uid
}
return filter, nil
}
func (h *Handler) accessFilterFromRequest(r *http.Request) (catalog.AccessFilter, bool, error) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
return catalog.AccessFilter{}, false, nil
}
filter, err := h.accessFilterForAuth(r.Context(), a)
return filter, true, err
}
func emptyAccessFilter() catalog.AccessFilter {
return catalog.AccessFilter{}
}
func sameABSPrincipal(a ctxAuth, userID, profileID string) bool {
return a.UserID == userID && a.ProfileID == profileID
}
// bearerAuth is the authentication middleware for protected ABS routes.
// It reads the bearer token from the Authorization header or ?token= query
// param, validates the JWT, checks the JTI isn't revoked, and injects
// ctxAuth into the request context.
//
// Placeholder implementation — full validation logic lands in Stage 2 when
// TokenStore and ConfigProvider are wired to real backing stores.
func (h *Handler) bearerAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if raw == "" {
raw = r.URL.Query().Get("token")
}
if raw == "" {
slog.DebugContext(r.Context(), "abs bearerAuth: no token", "component", "audiobooks", "path", r.URL.Path, "remote", r.RemoteAddr)
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
if h.deps.Config == nil || h.deps.TokenStore == nil {
slog.WarnContext(r.Context(), "abs bearerAuth: deps not wired", "component", "audiobooks",
"have_config", h.deps.Config != nil,
"have_token_store", h.deps.TokenStore != nil,
"path", r.URL.Path)
http.Error(w, "auth not configured", http.StatusServiceUnavailable)
return
}
secret, err := h.deps.Config.JWTSecret(r.Context())
if err != nil {
slog.ErrorContext(r.Context(), "abs bearerAuth: jwt secret fetch failed", "component", "audiobooks", "err", err, "path", r.URL.Path)
http.Error(w, "config unavailable", http.StatusInternalServerError)
return
}
claims, err := ParseToken(secret, raw)
if err != nil {
slog.DebugContext(r.Context(), "abs bearerAuth: parse failed", "component", "audiobooks", "err", err, "path", r.URL.Path)
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
if claims.Type != "access" {
slog.DebugContext(r.Context(), "abs bearerAuth: wrong token type", "component", "audiobooks", "type", claims.Type, "path", r.URL.Path)
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
row, err := h.deps.TokenStore.GetTokenByJTI(r.Context(), claims.JTI)
if err != nil {
slog.DebugContext(r.Context(), "abs bearerAuth: jti lookup failed", "component", "audiobooks",
"jti", claims.JTI, "err", err, "path", r.URL.Path)
http.Error(w, "token revoked", http.StatusUnauthorized)
return
}
if row.RevokedAt != nil {
slog.DebugContext(r.Context(), "abs bearerAuth: jti revoked", "component", "audiobooks", "jti", claims.JTI, "path", r.URL.Path)
http.Error(w, "token revoked", http.StatusUnauthorized)
return
}
if row.UserID != "" && row.UserID != claims.UserID {
slog.DebugContext(r.Context(), "abs bearerAuth: token user mismatch", "component", "audiobooks", "jti", claims.JTI, "path", r.URL.Path)
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
if row.ProfileID != "" && row.ProfileID != claims.ProfileID {
slog.DebugContext(r.Context(), "abs bearerAuth: token profile mismatch", "component", "audiobooks", "jti", claims.JTI, "path", r.URL.Path)
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
if row.Type != "" && row.Type != "access" {
slog.DebugContext(r.Context(), "abs bearerAuth: persisted token type mismatch", "component", "audiobooks", "type", row.Type, "path", r.URL.Path)
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
if !row.ExpiresAt.IsZero() && time.Now().After(row.ExpiresAt) {
slog.DebugContext(r.Context(), "abs bearerAuth: persisted token expired", "component", "audiobooks", "jti", claims.JTI, "path", r.URL.Path)
http.Error(w, "token expired", http.StatusUnauthorized)
return
}
_ = h.deps.TokenStore.TouchToken(r.Context(), claims.JTI)
var issuedAt time.Time
if claims.IssuedAt != nil {
issuedAt = claims.IssuedAt.Time
}
ctx := context.WithValue(r.Context(), ctxKey{}, ctxAuth{
UserID: claims.UserID,
ProfileID: claims.ProfileID,
JTI: claims.JTI,
Token: raw,
IssuedAt: issuedAt,
})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// ---------------------------------------------------------------------------
// Publisher nil-safe wrappers
// ---------------------------------------------------------------------------
func (h *Handler) publish(userID, event string, payload any) {
if h.deps.Publisher == nil {
return
}
h.deps.Publisher.Publish(userID, event, payload)
}
func (h *Handler) broadcast(event string, payload any) {
if h.deps.Publisher == nil {
return
}
h.deps.Publisher.Broadcast(event, payload)
}
// ---------------------------------------------------------------------------
// URL helpers
// ---------------------------------------------------------------------------
// absBaseURL returns the server address prefix ABS clients should use to
// resolve response-embedded URLs.
//
// - Host-proxied (X-Silo-User-Id header present): returns the plugin-proxy
// path "<scheme>://<host>/api/v1/plugins/<installID>".
// - Standalone listener: returns "<scheme>://<host>" — origin only.
//
// Honors X-Forwarded-Proto / X-Forwarded-Host for TLS-terminating proxies.
func (h *Handler) absBaseURL(r *http.Request) string {
scheme := r.Header.Get("X-Forwarded-Proto")
if scheme == "" {
if r.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
if r.Header.Get("X-Silo-User-Id") != "" {
return scheme + "://" + host + "/api/v1/plugins/" + h.deps.InstallID()
}
return scheme + "://" + host
}
// ---------------------------------------------------------------------------
// Shared response helpers (used by handlers across multiple stages)
// ---------------------------------------------------------------------------
// writeJSON serialises v as JSON and writes it with the given HTTP status.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// readPagedQuery extracts `limit` and `page` from query params. Real ABS
// treats limit=0 as "return all" (not "return zero rows") — we surface that
// intent and let callers short-circuit pagination.
func readPagedQuery(r *http.Request, defaultLimit int) (limit, page int) {
limit = defaultLimit
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
limit = n
}
}
if v := r.URL.Query().Get("page"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
page = n
}
}
return limit, page
}
// pagedEnvelope builds the standard ABS pagination response shape. All eight
// fields are always emitted (no omitempty) because ABS clients branch on
// their presence (sortBy, filterBy, minified).
func pagedEnvelope(results any, total, limit, page int, sortBy string, sortDesc bool, filterBy string, minified bool, include string) map[string]any {
return map[string]any{
"results": results,
"total": total,
"limit": limit,
"page": page,
"sortBy": sortBy,
"sortDesc": sortDesc,
"filterBy": filterBy,
"minified": minified,
"include": include,
}
}