openapi: 3.0.3 info: title: FablePool API version: 1.0.0 description: | FablePool is a self-hosted music streaming server that mounts libraries directly from S3-compatible object stores and WebDAV servers, with Chromecast support, an Android client, and an AI autoplay/recommendation engine. This document is the API contract for milestone 1. It covers two surfaces: 1. **REST v1** (`/api/v1/...`) — the first-party API used by the FablePool web UI, Android app, and Chromecast receiver. JSON only. Authenticated with JWT bearer tokens or long-lived API keys, plus short-lived signed *cast tokens* for media URLs handed to Chromecast devices (see `docs/05-auth-and-security.md` and `docs/06-chromecast.md`). 2. **Subsonic-compatible** (`/rest/...`) — a compatibility surface implementing the widely supported Subsonic API (target version **1.16.1**) so existing Subsonic/Navidrome clients work unmodified. Endpoints are listed here without the legacy `.view` suffix; the server MUST also accept the same path with a trailing `.view` (e.g. `/rest/ping` and `/rest/ping.view` are equivalent). All Subsonic endpoints accept both `GET` and `POST` (form-encoded); only `GET` is modeled here. **Pagination** (REST): collection endpoints use `limit`/`offset` and return `{ items, total, limit, offset }` envelopes. **Errors** (REST): non-2xx responses carry the `Error` schema with a stable machine-readable `code`. **Range requests**: `/api/v1/stream/{trackId}` and `/rest/stream` fully support `Range`/`206 Partial Content`, backed by S3 ranged GETs or WebDAV partial GETs as described in `docs/03-storage-abstraction.md`. license: name: AGPL-3.0-or-later url: https://www.gnu.org/licenses/agpl-3.0.html contact: name: FablePool project url: https://example.org/fablepool servers: - url: "{baseUrl}" description: FablePool server root (REST under /api/v1, Subsonic under /rest) variables: baseUrl: default: http://localhost:4533 tags: - name: auth description: Login, token refresh, API keys, current user. - name: users description: User administration (admin only). - name: sources description: S3 / WebDAV library source management and scanning (admin only). - name: library description: Browse artists, albums, tracks, genres; full-text search. - name: streaming description: Audio streaming, stream decision negotiation, cover art. - name: playlists description: Playlist CRUD and item management. - name: history description: Play history submission and retrieval. - name: reactions description: Like/dislike reactions on tracks. - name: recommendations description: Autoplay next-track recommendations and per-user autoplay settings. - name: cast description: Chromecast token minting and receiver configuration. - name: system description: Health, version, capabilities. - name: subsonic description: Subsonic 1.16.1 compatibility surface. security: - bearerAuth: [] - apiKeyAuth: [] paths: # --------------------------------------------------------------------------- # AUTH # --------------------------------------------------------------------------- /api/v1/auth/login: post: tags: [auth] operationId: login summary: Exchange username/password for a JWT token pair security: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/LoginRequest' responses: '200': description: Authenticated; token pair issued. content: application/json: schema: $ref: '#/components/schemas/TokenPair' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/RateLimited' /api/v1/auth/refresh: post: tags: [auth] operationId: refreshToken summary: Rotate a refresh token for a new token pair description: | Refresh tokens are single-use and rotated on every call. Reuse of a consumed refresh token revokes the whole token family (see `docs/05-auth-and-security.md`). security: [] requestBody: required: true content: application/json: schema: type: object required: [refreshToken] properties: refreshToken: type: string responses: '200': description: New token pair. content: application/json: schema: $ref: '#/components/schemas/TokenPair' '401': $ref: '#/components/responses/Unauthorized' /api/v1/auth/logout: post: tags: [auth] operationId: logout summary: Revoke the current refresh-token family responses: '204': description: Session revoked. '401': $ref: '#/components/responses/Unauthorized' /api/v1/auth/me: get: tags: [auth] operationId: getCurrentUser summary: Get the authenticated user responses: '200': description: Current user. content: application/json: schema: $ref: '#/components/schemas/User' '401': $ref: '#/components/responses/Unauthorized' /api/v1/auth/api-keys: get: tags: [auth] operationId: listApiKeys summary: List the caller's API keys responses: '200': description: API keys (secret material is never returned after creation). content: application/json: schema: type: array items: $ref: '#/components/schemas/ApiKey' '401': $ref: '#/components/responses/Unauthorized' post: tags: [auth] operationId: createApiKey summary: Create an API key requestBody: required: true content: application/json: schema: type: object required: [name] properties: name: type: string maxLength: 64 expiresAt: type: string format: date-time nullable: true responses: '201': description: | Key created. The plaintext `key` is returned exactly once and stored server-side only as a hash. content: application/json: schema: $ref: '#/components/schemas/ApiKeyCreated' '401': $ref: '#/components/responses/Unauthorized' '422': $ref: '#/components/responses/ValidationError' /api/v1/auth/api-keys/{keyId}: delete: tags: [auth] operationId: deleteApiKey summary: Revoke an API key parameters: - $ref: '#/components/parameters/KeyId' responses: '204': description: Key revoked. '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' # --------------------------------------------------------------------------- # USERS (admin) # --------------------------------------------------------------------------- /api/v1/users: get: tags: [users] operationId: listUsers summary: List users (admin) parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Offset' responses: '200': description: Users page. content: application/json: schema: $ref: '#/components/schemas/UserPage' '403': $ref: '#/components/responses/Forbidden' post: tags: [users] operationId: createUser summary: Create a user (admin) requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UserCreate' responses: '201': description: User created. content: application/json: schema: $ref: '#/components/schemas/User' '403': $ref: '#/components/responses/Forbidden' '409': $ref: '#/components/responses/Conflict' '422': $ref: '#/components/responses/ValidationError' /api/v1/users/{userId}: parameters: - $ref: '#/components/parameters/UserId' get: tags: [users] operationId: getUser summary: Get a user (admin, or self) responses: '200': description: User. content: application/json: schema: $ref: '#/components/schemas/User' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' patch: tags: [users] operationId: updateUser summary: Update a user (admin, or self for non-privileged fields) requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UserUpdate' responses: '200': description: Updated user. content: application/json: schema: $ref: '#/components/schemas/User' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '422': $ref: '#/components/responses/ValidationError' delete: tags: [users] operationId: deleteUser summary: Delete a user (admin) responses: '204': description: User deleted; their sessions and API keys are revoked. '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' # --------------------------------------------------------------------------- # SOURCES (S3 / WebDAV) + SCANNING (admin) # --------------------------------------------------------------------------- /api/v1/sources: get: tags: [sources] operationId: listSources summary: List configured library sources responses: '200': description: Sources (secrets redacted). content: application/json: schema: type: array items: $ref: '#/components/schemas/Source' '403': $ref: '#/components/responses/Forbidden' post: tags: [sources] operationId: createSource summary: Add an S3 or WebDAV source requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SourceCreate' responses: '201': description: Source created (not yet scanned). content: application/json: schema: $ref: '#/components/schemas/Source' '403': $ref: '#/components/responses/Forbidden' '422': $ref: '#/components/responses/ValidationError' /api/v1/sources/{sourceId}: parameters: - $ref: '#/components/parameters/SourceId' get: tags: [sources] operationId: getSource summary: Get a source responses: '200': description: Source. content: application/json: schema: $ref: '#/components/schemas/Source' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' patch: tags: [sources] operationId: updateSource summary: Update source configuration description: | Secret fields (`secretAccessKey`, `password`) are write-only; omit them to keep stored values, send a new value to rotate them. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SourceUpdate' responses: '200': description: Updated source. content: application/json: schema: $ref: '#/components/schemas/Source' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '422': $ref: '#/components/responses/ValidationError' delete: tags: [sources] operationId: deleteSource summary: Remove a source and all library entries derived from it responses: '204': description: Source removed. '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' /api/v1/sources/{sourceId}/test: post: tags: [sources] operationId: testSource summary: Test connectivity and credentials for a source description: | For S3: HEAD bucket + a single-page `ListObjectsV2` + one ranged GET on the first object found. For WebDAV: `OPTIONS` + `PROPFIND Depth: 1` on the root + one `Range` GET. Returns capability probes used by the stream decision engine (range support, presign support). parameters: - $ref: '#/components/parameters/SourceId' responses: '200': description: Probe results. content: application/json: schema: $ref: '#/components/schemas/SourceTestResult' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' /api/v1/sources/{sourceId}/scan: post: tags: [sources] operationId: startScan summary: Start (or queue) a scan of one source parameters: - $ref: '#/components/parameters/SourceId' requestBody: required: false content: application/json: schema: type: object properties: mode: type: string enum: [incremental, full] default: incremental description: > `incremental` diffs by ETag/Last-Modified; `full` re-reads all tags and re-derives audio features. responses: '202': description: Scan job accepted. content: application/json: schema: $ref: '#/components/schemas/ScanJob' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' '409': description: A scan for this source is already running. content: application/json: schema: $ref: '#/components/schemas/Error' /api/v1/sources/{sourceId}/scans: get: tags: [sources] operationId: listScans summary: List scan jobs for a source (most recent first) parameters: - $ref: '#/components/parameters/SourceId' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Offset' responses: '200': description: Scan jobs. content: application/json: schema: $ref: '#/components/schemas/ScanJobPage' '403': $ref: '#/components/responses/Forbidden' '404': $ref: '#/components/responses/NotFound' # --------------------------------------------------------------------------- # LIBRARY BROWSE / SEARCH # --------------------------------------------------------------------------- /api/v1/artists: get: tags: [library] operationId: listArtists summary: List artists parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Offset' - name: sort in: query schema: type: string enum: [name, albumCount, recentlyAdded] default: name - name: startsWith in: query description: Filter by sort-name initial (A–Z, or "#" for non-alpha). schema: type: string maxLength: 1 responses: '200': description: Artists page. content: application/json: schema: $ref: '#/components/schemas/ArtistPage' /api/v1/artists/{artistId}: get: tags: [library] operationId: getArtist summary: Get an artist parameters: - $ref: '#/components/parameters/ArtistId' responses: '200': description: Artist. content: application/json: schema: $ref: '#/components/schemas/Artist' '404': $ref: '#/components/responses/NotFound' /api/v1/artists/{artistId}/albums: get: tags: [library] operationId: listArtistAlbums summary: List an artist's albums parameters: - $ref: '#/components/parameters/ArtistId' - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Offset' responses: '200': description: Albums page. content: application/json: schema: $ref: '#/components/schemas/AlbumPage' '404': $ref: '#/components/responses/NotFound' /api/v1/albums: get: tags: [library] operationId: listAlbums summary: List albums parameters: - $ref: '#/components/parameters/Limit' - $ref: '#/components/parameters/Offset' - name: sort in: query schema: type: string enum: [name, artist, year, recentlyAdded, recentlyPlayed, mostPlayed, random] default: name - name: genre in: query schema: type: string - name: yearFrom in: query schema: type: integer - name: yearTo in: query schema: type: integer responses: '200': description: Albums page. content: application/json: schema: $ref: '#/components/schemas/AlbumPage' /api/v1/albums/{albumId}: get: tags: [library] operationId: getAlbum summary: Get an album parameters: - $ref: '#/components/parameters/AlbumId' responses: '200': description: Album. content: application/json: schema: $ref: '#/components/schemas/Album' '404': $ref: '#/components/responses/NotFound' /api/v1/albums/{albumId}/tracks: get: tags: [library] operationId: listAlbumTracks summary: List an album's tracks in disc/track order parameters: - $ref: '#/components/parameters/AlbumId' responses: '200': description: Tracks (whole album; not paginated). content: application/json: schema: type: array items: $ref: '#/components/schemas/Track' '404': $ref: '#/components/responses/NotFound' /api/v1/tracks/{trackId}: get: tags: [library] operationId: getTrack summary: Get a track parameters: - $ref: '#/components/parameters/TrackId' responses: '200': description: Track. content: application/json: schema: $ref: '#/components/schemas/Track' '404': $ref: '#/components/responses/NotFound' /api/v1/genres: get: tags: [library] operationId: listGenres summary: List genres with counts responses: '200': description: Genres. content: application/json: schema: type: array items: $ref: '#/components/schemas/Genre' /api/v1/search: get: tags: [library] operationId: search summary: Full-text search over artists, albums and tracks parameters: - name: q in: query required: true schema: type: string minLength: 1 - name: types in: query description: Comma-separated subset of `artist,album,track`. Default all. schema: type: string default: artist,album,track - name: limit in: query description: Per-type result limit. schema: type: integer minimum: 1 maximum: 100 default: 20 responses: '200': description: Search results grouped by type. content: application/json: schema: $ref: '#/components/schemas/SearchResults' # --------------------------------------------------------------------------- # STREAMING + ART # --------------------------------------------------------------------------- /api/v1/stream/{trackId}: get: tags: [streaming] operationId: streamTrack summary: Stream a track's audio description: | Serves the track per the same negotiation as `/decision`: * **proxy** — server pipes bytes from S3 (ranged `GetObject`) or WebDAV (`GET` with `Range`), honoring the client `Range` header and replying `206` with `Content-Range`. * **transcode** — server transcodes on the fly (see `docs/04-caching-and-transcoding.md`); `Range` is honored only for already-cached transcodes, otherwise the stream is chunked with `Accept-Ranges: none`. * **redirect** — `302` to a presigned S3 URL when `allowRedirect=true` and the source permits it. Authentication: bearer/API key as usual, or a `token` query parameter carrying a signed cast/stream token (for Chromecast and `