From 6d54aec98f0a24d9cfa10173f716f05027af10e7 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 18 May 2026 13:57:47 +0300 Subject: Rewrite api.md as exhaustive multi-client API contract - Expanded docs/api.md from 83 lines to full coverage: all 58 registered routes documented with method, both /api/ and /api/v1/ path aliases, request/response JSON schemas, status codes, and curl examples - Added Authentication section: Bearer token vs session cookie, auth precedence in RequireSession middleware, first-time bootstrap flow - Added API Versioning section: /api/ (legacy/web) vs /api/v1/ (stable contract), handleBoth convention, recommendation for mobile clients - Added Error Envelope section: {error: ...} documented once with full status code table - Added Token Lifecycle subsection: minting (one-time plaintext), expiry enforcement, last_used_at semantics, and revocation behaviour - Added Range header support documentation for streaming endpoints - Added Quick Reference table mapping every route to its auth level - Updated AGENTS.md with two new sections: Bearer-or-cookie unified middleware pattern in RequireSession, and handleBoth route registration convention to prevent multi-client contract drift Co-Authored-By: Claude Sonnet 4.6 --- player-server/docs/api.md | 1550 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 1472 insertions(+), 78 deletions(-) (limited to 'player-server/docs') diff --git a/player-server/docs/api.md b/player-server/docs/api.md index 0132300..8d1522f 100644 --- a/player-server/docs/api.md +++ b/player-server/docs/api.md @@ -1,84 +1,1478 @@ API Reference ============= -### Public - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/api/bootstrap` | Create the first admin account | -| `POST` | `/api/login` | Login | -| `GET` | `/healthz` | Liveness probe (no DB) | -| `GET` | `/readyz` | Readiness probe (DB ping) | -| `GET` | `/s/{token}` | View a shared media item | -| `GET` | `/s/{token}/stream` | Stream shared media | -| `GET` | `/s/{token}/thumbnail` | Thumbnail for shared media | -| `GET` | `/s/{token}/download` | Download shared media | - -### Session-required - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/api/logout` | Logout | -| `GET` | `/api/sets` | List sets | -| `GET` | `/api/sets/{id}/browse` | Browse a set (folder navigation) | -| `GET` | `/api/sets/{id}/cover` | Get set cover image | -| `POST` | `/api/sets/{id}/cover` | Update set cover image | -| `POST` | `/api/sets/{id}/upload` | Upload file to set | -| `GET` | `/api/media` | List/search media (with filters) | -| `GET` | `/api/media/{id}` | Get media details | -| `GET` | `/api/media/{id}/stream` | Stream media (range support) | -| `GET` | `/api/media/{id}/download` | Download original file | -| `GET` | `/api/media/{id}/thumbnail` | Get thumbnail | -| `POST` | `/api/media/{id}/thumbnail` | Regenerate thumbnail | -| `POST` | `/api/media/{id}/favorite` | Toggle favorite | -| `POST` | `/api/media/{id}/tags` | Add tag | -| `DELETE` | `/api/media/{id}/tags/{tag}` | Remove tag | -| `POST` | `/api/media/{id}/shares` | Create share link | -| `GET` | `/api/media/{id}/shares` | List shares for a media item | -| `GET` | `/api/media/{id}/notes` | Get note | -| `POST` | `/api/media/{id}/notes` | Upsert note | -| `DELETE` | `/api/media/{id}/notes` | Delete note | -| `POST` | `/api/progress` | Save playback progress | -| `DELETE` | `/api/media/{id}` | Soft-delete media | -| `POST` | `/api/media/{id}/restore` | Restore soft-deleted media | -| `GET` | `/api/shares` | List my shares | -| `DELETE` | `/api/shares/{token}` | Revoke share | -| `GET` | `/api/podcasts` | List podcasts | -| `GET` | `/api/podcasts/{id}/episodes` | List episodes | -| `POST` | `/api/podcasts/episodes/{id}/download` | Download episode to server | -| `POST` | `/api/podcasts/episodes/{id}/complete` | Toggle episode listened | - -### Admin-only - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/api/admin/users` | List users | -| `POST` | `/api/admin/users` | Create user | -| `DELETE` | `/api/admin/users/{id}` | Delete user | -| `GET` | `/api/admin/permissions` | List permissions | -| `POST` | `/api/admin/permissions` | Grant set permission | -| `DELETE` | `/api/admin/permissions` | Revoke set permission | -| `POST` | `/api/admin/rescan` | Trigger library rescan | -| `GET` | `/api/admin/scan-progress` | Get scan progress | -| `GET` | `/api/admin/trash` | List soft-deleted media | -| `POST` | `/api/podcasts` | Subscribe to podcast feed | - -### Query parameters for `GET /api/media` +This document is the authoritative contract for the Player HTTP API. It covers +every route registered in `internal/api/server.go`, including request/response +schemas, status codes, and curl examples. An Android developer can read this +document alone, mint a Bearer token, and begin implementing +`player-android/lib/api/player_api_client.dart` against `/api/v1/`. + +--- + +## Table of Contents + +1. [Authentication](#authentication) +2. [API Versioning](#api-versioning) +3. [Error Envelope](#error-envelope) +4. [Token Lifecycle](#token-lifecycle) +5. [Public Endpoints](#public-endpoints) +6. [Auth Endpoints](#auth-endpoints) +7. [Configuration](#configuration) +8. [Sets](#sets) +9. [Media](#media) +10. [Notes](#notes) +11. [Progress](#progress) +12. [Shares](#shares) +13. [Tags](#tags) +14. [Admin](#admin) +15. [Podcasts](#podcasts) + +--- + +## Authentication + +Every session-required endpoint accepts credentials via **either** of two +mechanisms, checked in this order: + +### 1. Bearer Token (recommended for API clients / mobile apps) + +Add an `Authorization` header carrying the token returned by +`POST /api/v1/auth/tokens`: + +``` +Authorization: Bearer pt_xxxxxxxxxxxxxxxxxxxx +``` + +Bearer tokens are long-lived (or non-expiring) and survive server restarts. +They are the correct choice for Android/Flutter clients. + +```bash +# Step 1: log in with username/password to get a session cookie +curl -s -c cookies.txt -X POST https://player.example.com/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "alice", "password": "secret"}' + +# Step 2: mint a Bearer token using that session cookie +curl -s -c cookies.txt -b cookies.txt \ + -X POST https://player.example.com/api/v1/auth/tokens \ + -H "Content-Type: application/json" \ + -d '{"name": "android-client", "expires_in_days": 365}' +``` + +Response: + +```json +{ + "id": 7, + "name": "android-client", + "token": "pt_xxxxxxxxxxxxxxxxxxxx" +} +``` + +Store that `token` value. It is the **only time** the plaintext value is +returned — subsequent `GET /api/v1/auth/tokens` responses omit it. + +### 2. Session Cookie (browser / web SPA) + +A `session=` `HttpOnly` cookie set by `POST /api/login` or +`POST /api/bootstrap`. The cookie is valid for the number of hours configured +via `SESSION_TIMEOUT_HOURS` (default 24 h). + +```bash +# Log in and save the session cookie +curl -s -c cookies.txt -X POST https://player.example.com/api/login \ + -H "Content-Type: application/json" \ + -d '{"username": "alice", "password": "secret"}' + +# Use the saved cookie on subsequent requests +curl -s -b cookies.txt https://player.example.com/api/v1/media +``` + +### Auth precedence in middleware + +`RequireSession` (in `internal/api/middleware.go`) tries Bearer first, then +falls back to the session cookie. If neither is present or valid it returns +`401 Unauthorized`. HTML-page requests (browsers sending `Accept: text/html`) +are redirected to `/login.html` instead. + +Endpoints that also require admin status apply `RequireAdmin` on top of +`RequireSession`. + +### First-time setup — Bootstrap + +Before any user exists the server redirects all requests to `/bootstrap.html`. +Call `POST /api/bootstrap` (or `POST /api/v1/auth/bootstrap`) to create the +first admin account: + +```bash +curl -s -X POST https://player.example.com/api/v1/auth/bootstrap \ + -H "Content-Type: application/json" \ + -d '{"username": "admin", "password": "changeme"}' +``` + +Subsequent calls return `403 Forbidden`. + +--- + +## API Versioning + +The server registers every session-required and admin route under **both** path +prefixes via the `handleBoth` helper in `server.go`: + +| Prefix | Purpose | +|--------|---------| +| `/api/` | Legacy / web-app path; kept for backwards compatibility with the browser SPA | +| `/api/v1/` | Stable contract; use this in new API clients | + +The two prefixes are **identical** in behaviour — they share the same handler. +Only the path changes. The `handleBoth` function panics at startup if a path +does not start with `/api/`, so all versioned paths are guaranteed to exist. + +Public endpoints (`/api/login`, `/api/bootstrap`) have dedicated v1 aliases +under `/api/v1/auth/login` and `/api/v1/auth/bootstrap` respectively. The +health probes (`/healthz`, `/readyz`) and share viewer (`/s/{token}/*`) have +no v1 alias — they are not API-version-sensitive. + +**Recommendation:** Android / Flutter clients should use `/api/v1/` for all +requests and include an `Authorization: Bearer ` header on every call. + +--- + +## Error Envelope + +All error responses (4xx and 5xx) use a consistent JSON envelope: + +```json +{ "error": "" } +``` + +Common status codes: + +| Code | Meaning | +|------|---------| +| `400` | Bad request — missing or invalid field | +| `401` | Unauthorized — missing or invalid credentials | +| `403` | Forbidden — authenticated but insufficient permission | +| `404` | Not found — resource does not exist or is inaccessible | +| `405` | Method not allowed | +| `410` | Gone — share link has expired | +| `413` | Request entity too large — upload exceeds `MAX_UPLOAD_SIZE_MB` | +| `500` | Internal server error | +| `501` | Not implemented — service dependency is unavailable | + +--- + +## Token Lifecycle + +API tokens are stored as bcrypt hashes in the `api_tokens` table. + +### Minting + +`POST /api/v1/auth/tokens` returns the plaintext token **once**. The server +stores only the hash. If you lose the plaintext, revoke the token and mint a +new one. + +### Expiry enforcement + +Every `AuthenticateBearer` call checks `expires_at` against the current time. +An expired token yields `401 Unauthorized`. Omit `expires_in_days` (or pass +`null`) to create a non-expiring token. + +### last_used_at semantics + +Each successful authentication via Bearer updates `last_used_at` on the token +row. The field is `null` if the token has never been used after creation. Use +`GET /api/v1/auth/tokens` to inspect it and audit unused tokens. + +### Revocation + +`DELETE /api/v1/auth/tokens/{id}` immediately removes the hash from the +database. Any subsequent request carrying that plaintext token returns `401`. +Only the owning user can revoke their own tokens. + +--- + +## Public Endpoints + +No credentials required. + +--- + +### `POST /api/bootstrap` · `POST /api/v1/auth/bootstrap` + +Create the first admin account. Returns `403` if users already exist. + +**Request body:** + +```json +{ "username": "admin", "password": "changeme" } +``` + +**Response `200`:** + +```json +{ "id": 1, "username": "admin", "is_admin": true } +``` + +Sets a `session` cookie. + +**Status codes:** `200`, `400`, `403`, `500` + +--- + +### `POST /api/login` · `POST /api/v1/auth/login` + +Authenticate with username and password. + +**Request body:** + +```json +{ "username": "alice", "password": "secret" } +``` + +**Response `200`:** + +```json +{ "id": 3, "username": "alice", "is_admin": false } +``` + +Sets a `session` cookie valid for `SESSION_TIMEOUT_HOURS` hours. + +**Status codes:** `200`, `400`, `401`, `500` + +--- + +### `GET /healthz` + +Liveness probe. Returns `200 OK` immediately; no database access. + +--- + +### `GET /readyz` + +Readiness probe. Pings the database. Returns `200 OK` or `503 Service +Unavailable`. + +--- + +### `GET /s/{token}` + +Renders the public share viewer page (HTML). When called with +`Accept: application/json` returns the share metadata as JSON. + +**Response `200` (JSON):** + +```json +{ + "media": { + "id": 42, + "file_name": "holiday.mp4", + "type": "video", + "duration": 3612.5, + "codec": "h264/aac", + "resolution": "1920x1080", + "bitrate": 4500, + "file_size_bytes": 2038431744 + }, + "has_thumb": true, + "stream_url": "/s/abc123/stream", + "download_url": "/s/abc123/download", + "thumb_url": "/s/abc123/thumbnail" +} +``` + +**Status codes:** `200`, `404`, `410` (expired share) + +--- + +### `GET /s/{token}/stream` + +Stream shared media. Supports the `Range` header for seeking (HTTP 206 partial +content). See [Range Header Support](#range-header-support) below. + +**Status codes:** `200`, `206`, `404`, `410` + +--- + +### `GET /s/{token}/thumbnail` + +Return the thumbnail image for a shared media item. + +**Status codes:** `200`, `404`, `410` + +--- + +### `GET /s/{token}/download` + +Download the original file for a shared media item. Sets +`Content-Disposition: attachment`. + +**Status codes:** `200`, `206`, `404`, `410` + +--- + +## Auth Endpoints + +Session required. Use Bearer token or session cookie. + +--- + +### `POST /api/logout` · `POST /api/v1/logout` + +Invalidate the current session cookie. Bearer-authenticated clients do not need +to call this — revoke the token instead. + +**Response `204 No Content`** (no body). + +--- + +### `POST /api/auth/tokens` · `POST /api/v1/auth/tokens` + +Mint a new Bearer API token for the authenticated user. + +**Request body:** + +```json +{ + "name": "android-client", + "expires_in_days": 365 +} +``` + +`expires_in_days` is optional. Omit or pass `null` for a non-expiring token. +Pass an integer > 0 to set an expiry. + +**Response `200`:** + +```json +{ + "id": 7, + "name": "android-client", + "token": "pt_xxxxxxxxxxxxxxxxxxxx" +} +``` + +The `token` field is the **plaintext Bearer value** — it will not appear again. +Store it securely. + +**Status codes:** `200`, `400`, `401`, `500` + +```bash +curl -s -X POST https://player.example.com/api/v1/auth/tokens \ + -H "Authorization: Bearer pt_xxxxxxxxxxxxxxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '{"name": "ci-token"}' +``` + +--- + +### `GET /api/auth/tokens` · `GET /api/v1/auth/tokens` + +List API tokens belonging to the authenticated user. Plaintext values are never +returned here. + +**Response `200`:** + +```json +[ + { + "id": 7, + "name": "android-client", + "last_used_at": "2026-05-17T10:00:00Z", + "expires_at": "2027-05-17T10:00:00Z", + "created_at": "2026-05-17T09:00:00Z" + } +] +``` + +`last_used_at` and `expires_at` are `null` when not set. + +**Status codes:** `200`, `401`, `500` + +--- + +### `DELETE /api/auth/tokens/{id}` · `DELETE /api/v1/auth/tokens/{id}` + +Revoke a token by its numeric ID. Only the owning user can revoke their own +tokens. Immediate effect — any in-flight request using the token will fail. + +**Response `204 No Content`** (no body). + +**Status codes:** `204`, `400`, `401`, `404`, `500` + +```bash +curl -s -X DELETE https://player.example.com/api/v1/auth/tokens/7 \ + -H "Authorization: Bearer pt_xxxxxxxxxxxxxxxxxxxx" +``` + +--- + +## Configuration + +### `GET /api/config` · `GET /api/v1/config` + +Return client configuration. Currently exposes the server-side page size so +clients can paginate consistently. + +**Response `200`:** + +```json +{ "media_page_size": 100 } +``` + +**Status codes:** `200`, `401` + +--- + +## Sets + +A **set** is a top-level collection corresponding to a subdirectory of +`MEDIA_ROOT`. Users see only sets they have been granted access to (admins see +all sets). + +--- + +### `GET /api/sets` · `GET /api/v1/sets` + +List all sets visible to the authenticated user. + +**Response `200`:** + +```json +[ + { + "id": 1, + "name": "Movies", + "root_path": "movies", + "cover_thumbnail_path": "/media/movies/.cover.jpg", + "is_podcast": false, + "permissions": [ + { "set_id": 1, "user_id": 3, "role": "viewer", "created_at": "2026-01-01T00:00:00Z" } + ], + "created_at": "2026-01-01T00:00:00Z" + } +] +``` + +**Status codes:** `200`, `401`, `500` + +```bash +curl -s https://player.example.com/api/v1/sets \ + -H "Authorization: Bearer pt_xxxxxxxxxxxxxxxxxxxx" +``` + +--- + +### `GET /api/sets/{id}/browse` · `GET /api/v1/sets/{id}/browse` + +Browse the folder tree within a set. Pass `?parent=subfolder` to navigate into +a subdirectory. + +**Query parameters:** | Parameter | Description | |-----------|-------------| -| `search` | Plain text search on file name | -| `set_id` | Filter by a single set ID | -| `set_ids` | Filter by comma-separated set IDs | -| `type` | Filter by media type (e.g. `video`, `audio`, `image`) | -| `favorites` | `true` or `1` to show favorites only | -| `tags` | Comma-separated tag names | -| `min_duration` | Minimum duration in minutes | -| `max_duration` | Maximum duration in minutes | -| `filesize_min` | Minimum file size in MB | -| `filesize_max` | Maximum file size in MB | -| `sort` | Sort order (e.g. `random`) | -| `limit` | Page size | -| `offset` | Page offset | -| `folder` | Filter to a specific folder path | -| `parent` | Filter to items within a parent folder | \ No newline at end of file +| `parent` | Relative folder path within the set (default: root) | + +**Response `200`:** + +```json +{ + "current_path": "movies/action", + "folders": [ + { "name": "2023", "has_cover": true } + ], + "media": [ { /* Media object — see Media schema */ } ], + "episodes": [] +} +``` + +`episodes` is present and non-empty only when the set is a podcast set. + +**Status codes:** `200`, `400`, `401`, `403`, `500` + +--- + +### `GET /api/sets/{id}/cover` · `GET /api/v1/sets/{id}/cover` + +Return the cover image for a set or folder. Returns the image bytes directly. + +**Query parameters:** + +| Parameter | Description | +|-----------|-------------| +| `folder` | Subfolder within the set (optional) | + +**Status codes:** `200`, `400`, `401`, `403`, `404`, `500` + +--- + +### `POST /api/sets/{id}/cover` · `POST /api/v1/sets/{id}/cover` + +Regenerate the cover image for a set or folder (owner or admin only). + +**Query parameters:** + +| Parameter | Description | +|-----------|-------------| +| `folder` | Subfolder to regenerate the cover for (optional) | + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `403`, `404`, `500` + +--- + +### `POST /api/sets/{id}/upload` · `POST /api/v1/sets/{id}/upload` + +Upload a media file to a set. Requires `owner` permission on the set. + +**Request:** `multipart/form-data` with a single field named `file`. + +Maximum upload size is controlled by `MAX_UPLOAD_SIZE_MB` (default 100 MB). + +**Response `200`:** The newly created `Media` object (see [Media schema](#media-schema)). + +**Status codes:** `200`, `400`, `401`, `403`, `404`, `413`, `500` + +```bash +curl -s -X POST https://player.example.com/api/v1/sets/1/upload \ + -H "Authorization: Bearer pt_xxxxxxxxxxxxxxxxxxxx" \ + -F file=@/path/to/video.mp4 +``` + +--- + +## Media + +### Media Schema + +All endpoints that return a media item use this shape: + +```json +{ + "id": 42, + "set_id": 1, + "rel_path": "action/movie.mp4", + "file_name": "movie.mp4", + "abs_path": "/media/movies/action/movie.mp4", + "type": "video", + "duration": 7200.0, + "codec": "h264/aac", + "resolution": "1920x1080", + "bitrate": 4500, + "file_size_bytes": 4294967296, + "width": 1920, + "height": 1080, + "exif_camera": "", + "exif_lens": "", + "exif_date": "", + "exif_iso": "", + "exif_f_number": "", + "exif_exposure": "", + "exif_focal_length": "", + "thumbnail_path": "/media/movies/.thumbs/movie.jpg", + "play_count": 3, + "deleted_at": null, + "created_at": "2026-01-15T12:00:00Z" +} +``` + +`type` is one of `"video"`, `"audio"`, or `"image"`. + +--- + +### `GET /api/media` · `GET /api/v1/media` + +List or search media visible to the authenticated user. Supports rich filtering. + +**Query parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `search` | string | Plain-text search on file name / path | +| `set_id` | integer | Filter to a single set | +| `set_ids` | string | Comma-separated set IDs | +| `type` | string | `video`, `audio`, or `image` | +| `favorites` | string | `true` or `1` to show favourites only | +| `tags` | string | Comma-separated tag names (AND match) | +| `min_duration` | float | Minimum duration in minutes | +| `max_duration` | float | Maximum duration in minutes | +| `filesize_min` | integer | Minimum file size in MB | +| `filesize_max` | integer | Maximum file size in MB | +| `sort` | string | `name`, `date`, `duration`, `play_count`, or `random` | +| `limit` | integer | Page size (1–1000, default 100) | +| `offset` | integer | Page offset (default 0) | +| `folder` | string | Filter to an exact folder path | +| `parent` | string | Filter to items inside a parent folder | + +**Response `200`:** Array of `Media` objects. + +**Status codes:** `200`, `401`, `500` + +```bash +# List recent videos from set 1, page 2 +curl -s "https://player.example.com/api/v1/media?set_id=1&type=video&limit=20&offset=20" \ + -H "Authorization: Bearer pt_xxxxxxxxxxxxxxxxxxxx" +``` + +--- + +### `GET /api/media/{id}` · `GET /api/v1/media/{id}` + +Return a single media item with related data (tags, favorite state, note, +and saved progress position). + +**Response `200`:** + +```json +{ + "media": { /* Media object */ }, + "tags": [ { "id": 3, "name": "documentary" } ], + "favorite": false, + "note": null, + "progress": { + "user_id": 3, + "media_id": 42, + "position_seconds": 1234.5, + "finished": false, + "updated_at": "2026-05-10T08:00:00Z" + } +} +``` + +`note` and `progress` are `null` when absent. + +**Status codes:** `200`, `400`, `401`, `404`, `500` + +--- + +### `GET /api/media/{id}/stream` · `GET /api/v1/media/{id}/stream` + +Stream a media file. The server sets `Accept-Ranges: bytes` and handles the +`Range` header natively, so clients can seek without downloading the entire +file. + +#### Range Header Support + +Standard HTTP range requests are supported on all streaming and download +endpoints: + +``` +Range: bytes=0-1048575 +``` + +The server responds with `206 Partial Content` when a valid `Range` header is +present, `200 OK` otherwise. + +When the file requires remuxing (e.g. `.mkv` files), the server streams the +remuxed output and sets: + +``` +X-Duration: +Content-Type: video/mp4 +Cache-Control: no-store +``` + +Remuxed streams do not support range requests. + +**Status codes:** `200`, `206`, `400`, `401`, `403`, `404`, `500` + +```bash +# Stream from byte offset 10 MB +curl -s -r 10485760- https://player.example.com/api/v1/media/42/stream \ + -H "Authorization: Bearer pt_xxxxxxxxxxxxxxxxxxxx" \ + -o segment.mp4 +``` + +--- + +### `GET /api/media/{id}/download` · `GET /api/v1/media/{id}/download` + +Download the original file with `Content-Disposition: attachment`. Supports +`Range` header. + +**Status codes:** `200`, `206`, `400`, `401`, `403`, `404`, `500` + +--- + +### `GET /api/media/{id}/thumbnail` · `GET /api/v1/media/{id}/thumbnail` + +Return the thumbnail image for a media item (JPEG). Sets `Cache-Control: +no-cache`. + +**Status codes:** `200`, `400`, `401`, `403`, `404`, `500` + +--- + +### `POST /api/media/{id}/thumbnail` · `POST /api/v1/media/{id}/thumbnail` + +Regenerate a media item's thumbnail using ffmpeg. Requires `owner` permission. + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `403`, `404`, `500` + +--- + +### `POST /api/media/{id}/favorite` · `POST /api/v1/media/{id}/favorite` + +Toggle the authenticated user's favourite status for a media item. + +**Response `200`:** + +```json +{ "favorite": true } +``` + +`favorite` reflects the **new** state after the toggle. + +**Status codes:** `200`, `400`, `401`, `404`, `500` + +--- + +### `DELETE /api/media/{id}` · `DELETE /api/v1/media/{id}` + +Soft-delete a media item. The item is moved to trash and is no longer returned +by `GET /api/media`. Requires `owner` permission or admin. + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `403`, `404`, `500` + +--- + +### `POST /api/media/{id}/restore` · `POST /api/v1/media/{id}/restore` + +Restore a soft-deleted media item from trash. Requires `owner` permission or +admin. + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `403`, `404`, `500` + +--- + +### `GET /api/media/{id}/playback` · `GET /api/v1/media/{id}/playback` + +Return codec and container metadata to help the client decide whether to play +natively or defer to a transcoded stream. + +**Response `200`:** + +```json +{ + "stream_url": "/api/v1/media/42/stream", + "container": "mp4", + "video_codec": "h264", + "audio_codec": "aac", + "duration_seconds": 7200.0, + "file_size_bytes": 4294967296, + "width": 1920, + "height": 1080, + "bitrate": 4500, + "needs_transcode": false +} +``` + +`needs_transcode: true` indicates the file will be remuxed server-side when +streamed. Native containers include `mp4`, `webm`, `ogg`, `mp3`, `m4a`, `wav`, +`aac`, and `opus`. Native video codecs include `h264`, `vp8`, `vp9`, `av1`, +`hevc`, and `theora`. Native audio codecs include `aac`, `mp3`, `opus`, and +`vorbis`. + +**Status codes:** `200`, `400`, `401`, `404`, `500` + +```bash +curl -s https://player.example.com/api/v1/media/42/playback \ + -H "Authorization: Bearer pt_xxxxxxxxxxxxxxxxxxxx" +``` + +--- + +### `POST /api/media/{id}/shares` · `POST /api/v1/media/{id}/shares` + +Create a public share link for a media item. Expiry is controlled by +`SHARE_DEFAULT_EXPIRY_DAYS` (default 7 days). + +**Response `200`:** + +```json +{ + "token": "abc123xyz", + "media_id": 42, + "created_by": 3, + "created_at": "2026-05-17T10:00:00Z", + "expires_at": "2026-05-24T10:00:00Z", + "max_uses": null, + "used_count": 0 +} +``` + +Share the public URL: `https://player.example.com/s/` + +**Status codes:** `200`, `400`, `401`, `404`, `500` + +--- + +### `GET /api/media/{id}/shares` · `GET /api/v1/media/{id}/shares` + +List active shares for a specific media item. + +**Response `200`:** Array of `Share` objects (same schema as above). + +**Status codes:** `200`, `400`, `401`, `404`, `500` + +--- + +## Notes + +A note is a per-user, per-media free-text annotation. + +--- + +### `GET /api/media/{id}/notes` · `GET /api/v1/media/{id}/notes` + +Return the authenticated user's note for a media item. + +**Response `200`:** + +```json +{ + "id": 5, + "media_id": 42, + "user_id": 3, + "content": "My viewing notes here.", + "created_at": "2026-01-20T09:00:00Z", + "updated_at": "2026-04-01T14:00:00Z" +} +``` + +**Response `204 No Content`** when no note exists (no body). + +**Status codes:** `200`, `204`, `400`, `401`, `500` + +--- + +### `POST /api/media/{id}/notes` · `POST /api/v1/media/{id}/notes` + +Create or update (upsert) the authenticated user's note for a media item. + +**Request body:** + +```json +{ "content": "My viewing notes here." } +``` + +**Response `200`:** The updated `Note` object. + +**Status codes:** `200`, `400`, `401`, `500` + +--- + +### `DELETE /api/media/{id}/notes` · `DELETE /api/v1/media/{id}/notes` + +Delete the authenticated user's note for a media item. + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `500` + +--- + +## Progress + +Playback progress tracks the last known position for each media item per user. +Progress also drives the 60-second accumulator that increments `play_count`. + +--- + +### `POST /api/progress` · `POST /api/v1/progress` + +Save a playback position for a single media item. Call this periodically while +the user is playing media (e.g. every 10–30 seconds). + +**Request body:** + +```json +{ + "media_id": 42, + "position_seconds": 1234.5 +} +``` + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `500` + +```bash +curl -s -X POST https://player.example.com/api/v1/progress \ + -H "Authorization: Bearer pt_xxxxxxxxxxxxxxxxxxxx" \ + -H "Content-Type: application/json" \ + -d '{"media_id": 42, "position_seconds": 1234.5}' +``` + +--- + +### `POST /api/progress/batch` · `POST /api/v1/progress/batch` + +Submit multiple progress updates in one call. Designed for offline clients that +accumulate updates while disconnected and sync on reconnect. Updates are +processed in `observed_at` order — older updates do not overwrite newer ones. + +**Request body:** + +```json +{ + "updates": [ + { + "media_id": 42, + "position_seconds": 500.0, + "observed_at": "2026-05-17T08:00:00Z" + }, + { + "media_id": 43, + "position_seconds": 120.0, + "observed_at": "2026-05-17T08:05:00Z" + } + ] +} +``` + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `500` + +--- + +### `POST /api/progress/status` · `POST /api/v1/progress/status` + +Mark a media item as finished or reset its progress. + +**Request body:** + +```json +{ + "media_id": 42, + "status": "finished" +} +``` + +`status` must be one of: +- `"finished"` — mark as fully watched/listened +- `"not_started"` — clear position and playback counters + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `500` + +--- + +### `GET /api/in-progress` · `GET /api/v1/in-progress` + +Return media items the authenticated user has started but not finished. + +**Response `200`:** Array of `Media` objects (same schema as `GET /api/media`). + +**Status codes:** `200`, `401`, `500` + +```bash +curl -s https://player.example.com/api/v1/in-progress \ + -H "Authorization: Bearer pt_xxxxxxxxxxxxxxxxxxxx" +``` + +--- + +## Shares + +These endpoints manage the authenticated user's own share links. + +--- + +### `GET /api/shares` · `GET /api/v1/shares` + +List all share links created by the authenticated user. + +**Response `200`:** + +```json +[ + { + "token": "abc123xyz", + "media_id": 42, + "file_name": "movie.mp4", + "media_type": "video", + "created_at": "2026-05-17T10:00:00Z", + "expires_at": "2026-05-24T10:00:00Z", + "max_uses": null, + "used_count": 2 + } +] +``` + +**Status codes:** `200`, `401`, `500` + +--- + +### `DELETE /api/shares/{token}` · `DELETE /api/v1/shares/{token}` + +Revoke a share link. Only the creator can revoke their own share. + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `404`, `500` + +--- + +## Tags + +--- + +### `GET /api/tags` · `GET /api/v1/tags` + +Return all tag names visible to the authenticated user. + +**Response `200`:** + +```json +[ + { "id": 1, "name": "documentary" }, + { "id": 2, "name": "4k" } +] +``` + +**Status codes:** `200`, `401`, `500` + +--- + +### `POST /api/media/{id}/tags` · `POST /api/v1/media/{id}/tags` + +Add a tag to a media item. + +**Request body:** + +```json +{ "tag": "documentary" } +``` + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `404`, `500` + +--- + +### `DELETE /api/media/{id}/tags/{tag}` · `DELETE /api/v1/media/{id}/tags/{tag}` + +Remove a tag from a media item. `{tag}` is the URL-encoded tag name. + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `404`, `500` + +--- + +## Admin + +All admin endpoints require the authenticated user to have `is_admin: true`. +Returns `403 Forbidden` for non-admin users. + +--- + +### `GET /api/admin/users` · `GET /api/v1/admin/users` + +List all user accounts. + +**Response `200`:** + +```json +[ + { + "id": 1, + "username": "admin", + "is_admin": true, + "created_at": "2026-01-01T00:00:00Z" + } +] +``` + +**Status codes:** `200`, `401`, `403`, `500` + +--- + +### `POST /api/admin/users` · `POST /api/v1/admin/users` + +Create a new user account. + +**Request body:** + +```json +{ + "username": "bob", + "password": "hunter2", + "is_admin": false +} +``` + +**Response `200`:** The created `User` object. + +**Status codes:** `200`, `400`, `401`, `403`, `500` + +--- + +### `DELETE /api/admin/users/{id}` · `DELETE /api/v1/admin/users/{id}` + +Delete a user account. Admins cannot delete themselves. + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `403`, `500` + +--- + +### `GET /api/admin/permissions` · `GET /api/v1/admin/permissions` + +Return the full permission matrix (sets, users, and permission rows). + +**Response `200`:** + +```json +{ + "sets": [ { /* Set object */ } ], + "users": [ { /* User object */ } ], + "permissions": [ + { "set_id": 1, "user_id": 3, "role": "viewer", "created_at": "2026-01-01T00:00:00Z" } + ] +} +``` + +**Status codes:** `200`, `401`, `403`, `500` + +--- + +### `POST /api/admin/permissions` · `POST /api/v1/admin/permissions` + +Grant a user access to a set. + +**Request body:** + +```json +{ + "set_id": 1, + "user_id": 3, + "role": "viewer" +} +``` + +`role` is one of `"owner"` or `"viewer"`. + +- `owner` — can upload, soft-delete/restore media, and regenerate thumbnails +- `viewer` — can browse and play media + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `403`, `500` + +--- + +### `DELETE /api/admin/permissions` · `DELETE /api/v1/admin/permissions` + +Revoke a user's access to a set. + +**Request body:** + +```json +{ "set_id": 1, "user_id": 3 } +``` + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `400`, `401`, `403`, `500` + +--- + +### `POST /api/admin/rescan` · `POST /api/v1/admin/rescan` + +Trigger a library rescan. The scan runs asynchronously. Poll +`GET /api/admin/scan-progress` to track progress. + +**Response `200`:** + +```json +{ "status": "ok" } +``` + +**Status codes:** `200`, `401`, `403`, `500` + +--- + +### `GET /api/admin/scan-progress` · `GET /api/v1/admin/scan-progress` + +Return the current or most recent scan state. + +**Response `200`:** + +```json +{ + "running": true, + "current_set": "Movies", + "sets_total": 5, + "sets_done": 2, + "files_total": 1200, + "files_done": 480, + "last_error": "" +} +``` + +**Status codes:** `200`, `401`, `403`, `500` + +--- + +### `GET /api/admin/trash` · `GET /api/v1/admin/trash` + +List all soft-deleted media items. + +**Response `200`:** Array of `Media` objects with `deleted_at` set. + +**Status codes:** `200`, `401`, `403`, `500` + +--- + +## Podcasts + +Podcasts are stored in a special set with `is_podcast: true`. Each feed gets +its own subfolder inside the shared podcast set. + +--- + +### `GET /api/podcasts` · `GET /api/v1/podcasts` + +List all subscribed podcast feeds visible to the authenticated user. + +**Response `200`:** + +```json +[ + { + "id": 1, + "set_id": 4, + "feed_url": "https://example.com/feed.rss", + "title": "My Podcast", + "description": "A great podcast.", + "image_url": "https://example.com/cover.jpg", + "last_checked_at": "2026-05-17T06:00:00Z", + "last_etag": "\"abc123\"", + "check_interval_minutes": 60, + "auto_download": false, + "consecutive_failures": 0, + "next_check_at": "2026-05-17T07:00:00Z", + "created_at": "2026-01-10T00:00:00Z" + } +] +``` + +**Status codes:** `200`, `401`, `500` + +--- + +### `POST /api/podcasts` · `POST /api/v1/podcasts` + +Subscribe to a new podcast feed. Admin only. + +**Request body:** + +```json +{ + "feed_url": "https://example.com/feed.rss", + "set_name": "Tech Podcasts" +} +``` + +`set_name` is optional. When omitted the server uses the feed title. + +**Response `200`:** The created `PodcastFeed` object. + +**Status codes:** `200`, `400`, `401`, `403`, `500` + +--- + +### `GET /api/podcasts/{id}/episodes` · `GET /api/v1/podcasts/{id}/episodes` + +List episodes for a podcast feed. `{id}` is the **set ID** (not the feed ID). + +**Query parameters:** + +| Parameter | Description | +|-----------|-------------| +| `limit` | Page size (default 50) | +| `offset` | Page offset (default 0) | + +**Response `200`:** + +```json +[ + { + "id": 10, + "feed_id": 1, + "media_id": null, + "guid": "episode-guid-001", + "title": "Episode 1: Introduction", + "description": "The first episode.", + "published_at": "2026-01-05T00:00:00Z", + "episode_url": "https://example.com/ep1.mp3", + "duration_seconds": 3600.0, + "file_size": 52428800, + "file_name": "ep1.mp3", + "is_downloaded": false, + "created_at": "2026-01-05T01:00:00Z", + "is_completed": false, + "position_seconds": 0.0 + } +] +``` + +`media_id` is `null` until the episode is downloaded. `is_completed` and +`position_seconds` are per-user. + +**Status codes:** `200`, `400`, `401`, `403`, `404`, `500` + +--- + +### `POST /api/podcasts/episodes/{episode_id}/download` · `POST /api/v1/podcasts/episodes/{episode_id}/download` + +Trigger a server-side download of a podcast episode. The episode is downloaded +to the podcast set directory and a `Media` row is created. + +**Response `200`:** The newly created `Media` object. + +**Status codes:** `200`, `400`, `401`, `403`, `404`, `500` + +--- + +### `POST /api/podcasts/episodes/{episode_id}/complete` · `POST /api/v1/podcasts/episodes/{episode_id}/complete` + +Toggle the per-user completion state of a podcast episode. + +**Response `204 No Content`** (no body). + +**Status codes:** `204`, `400`, `401`, `403`, `404`, `500` + +--- + +## Quick Reference + +### Route Summary + +| Method | Path (`/api/v1/` prefix) | Auth | Description | +|--------|--------------------------|------|-------------| +| `POST` | `auth/bootstrap` | none | Create first admin account | +| `POST` | `auth/login` | none | Login and receive session cookie | +| `GET` | `—` `/healthz` | none | Liveness probe | +| `GET` | `—` `/readyz` | none | Readiness probe | +| `GET` | `—` `/s/{token}` | none | Share viewer page | +| `GET` | `—` `/s/{token}/stream` | none | Stream shared media | +| `GET` | `—` `/s/{token}/thumbnail` | none | Shared media thumbnail | +| `GET` | `—` `/s/{token}/download` | none | Download shared media | +| `POST` | `logout` | session | Logout | +| `POST` | `auth/tokens` | session | Mint API token | +| `GET` | `auth/tokens` | session | List API tokens | +| `DELETE` | `auth/tokens/{id}` | session | Revoke API token | +| `GET` | `config` | session | Client configuration | +| `GET` | `sets` | session | List sets | +| `GET` | `sets/{id}/browse` | session | Browse set folders | +| `GET` | `sets/{id}/cover` | session | Get set cover image | +| `POST` | `sets/{id}/cover` | session | Regenerate set cover | +| `POST` | `sets/{id}/upload` | session | Upload file to set | +| `GET` | `media` | session | List/search media | +| `GET` | `media/{id}` | session | Get media detail | +| `GET` | `media/{id}/stream` | session | Stream media (range) | +| `GET` | `media/{id}/download` | session | Download media file | +| `GET` | `media/{id}/thumbnail` | session | Get thumbnail | +| `POST` | `media/{id}/thumbnail` | session | Regenerate thumbnail | +| `POST` | `media/{id}/favorite` | session | Toggle favourite | +| `DELETE` | `media/{id}` | session | Soft-delete media | +| `POST` | `media/{id}/restore` | session | Restore from trash | +| `GET` | `media/{id}/playback` | session | Playback hints | +| `POST` | `media/{id}/shares` | session | Create share link | +| `GET` | `media/{id}/shares` | session | List shares for media | +| `GET` | `media/{id}/notes` | session | Get note | +| `POST` | `media/{id}/notes` | session | Upsert note | +| `DELETE` | `media/{id}/notes` | session | Delete note | +| `GET` | `tags` | session | List tags | +| `POST` | `media/{id}/tags` | session | Add tag | +| `DELETE` | `media/{id}/tags/{tag}` | session | Remove tag | +| `POST` | `progress` | session | Save progress | +| `POST` | `progress/batch` | session | Batch save progress | +| `POST` | `progress/status` | session | Mark finished/not-started | +| `GET` | `in-progress` | session | List in-progress media | +| `GET` | `shares` | session | List my shares | +| `DELETE` | `shares/{token}` | session | Revoke share | +| `GET` | `podcasts` | session | List podcast feeds | +| `POST` | `podcasts` | admin | Subscribe to feed | +| `GET` | `podcasts/{id}/episodes` | session | List episodes | +| `POST` | `podcasts/episodes/{episode_id}/download` | session | Download episode | +| `POST` | `podcasts/episodes/{episode_id}/complete` | session | Toggle completion | +| `GET` | `admin/users` | admin | List users | +| `POST` | `admin/users` | admin | Create user | +| `DELETE` | `admin/users/{id}` | admin | Delete user | +| `GET` | `admin/permissions` | admin | List permissions | +| `POST` | `admin/permissions` | admin | Grant permission | +| `DELETE` | `admin/permissions` | admin | Revoke permission | +| `POST` | `admin/rescan` | admin | Trigger rescan | +| `GET` | `admin/scan-progress` | admin | Get scan progress | +| `GET` | `admin/trash` | admin | List soft-deleted media | -- cgit v1.2.3