summaryrefslogtreecommitdiff
path: root/player-server/docs
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-18 13:57:47 +0300
committerPaul Buetow <paul@buetow.org>2026-05-18 13:57:47 +0300
commit6d54aec98f0a24d9cfa10173f716f05027af10e7 (patch)
tree88c00972908db28e418251fee132d55e700fe2aa /player-server/docs
parent0734a296a581917d2fd60e6a1a7aa36ead3a2546 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'player-server/docs')
-rw-r--r--player-server/docs/api.md1550
1 files changed, 1472 insertions, 78 deletions
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=<value>` `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 <token>` header on every call.
+
+---
+
+## Error Envelope
+
+All error responses (4xx and 5xx) use a consistent JSON envelope:
+
+```json
+{ "error": "<human-readable message>" }
+```
+
+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: <seconds as float>
+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/<token>`
+
+**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:**
<