# API reference All endpoints are relative to the app's own origin (one server, one port β€” no CORS to think about in production). JSON in, JSON out, except where noted (binary media, Prometheus text, file downloads). **Auth**: every route is session-gated **except** the ones marked πŸ”“ below, and except the few read-only endpoints an [API key](#api-key) may `GET` (`/healthz`, `/status`, `/metrics`, `/api/version`, and everything under `/api/auth/*`). See [AUTHENTICATION.md](AUTHENTICATION.md) for how sessions work. An ungated request to a protected route gets `401 {"error": "authentication required"}`. **Errors**: most handlers return `anyhow::Error` wrapped into a `500 {"error": ""}` by a shared `AppError` type. Handlers that need a more specific status build it directly β€” `400` for bad input, `404` for "not found", `409` for a conflicting state (e.g. vacuuming while an index pass is running). Check each endpoint below for its specific non-500 cases. - **Unknown API paths** (anything under `/api` that isn't an endpoint below, including a stray trailing slash) answer `404 {"error": "no such API endpoint"}`, not the web page. An unauthenticated caller still gets the `401` first, so the paths that exist aren't revealed. A known path with the wrong method is `405`. - **No media server configured** is `409` with the explanation (e.g. `GET /api/libraries`), not `500`: it's a state to fix in Settings, not a server fault. - **Range requests.** `GET /api/download/{filename}` and `GET /api/preview/{rating_key}` honour a single `Range: bytes=` (explicit `a-b`, open-ended `a-`, suffix `-n`) with `206` and `Content-Range`, and answer an unsatisfiable range with `416` and `Content-Range: bytes */`. A multi-range or malformed `Range` is ignored and the whole file is returned with `200` (RFC 9110 allows that). --- ## Health, status, metrics ### `GET /healthz` πŸ”“ Bare liveness check for things like Docker's `HEALTHCHECK`. ```json { "ok": true } ``` ### `GET /status` πŸ”“ Slightly richer liveness check for uptime monitors/status pages. No DB query β€” in-memory only, stays fast even if SQLite were stuck. ```json { "status": "ok", "version": "v0.11.0", "uptime_seconds": 1234, "index_running": false } ``` ### `GET /metrics` πŸ”“ Prometheus text exposition format. See [OBSERVABILITY.md](OBSERVABILITY.md) for the full metric list and an example Grafana dashboard (`grafana/meme-arr-dashboard.json`). ### `GET /api/version` πŸ”“ Current build version and whether a newer GitHub release tag exists. `checked: false` means the GitHub lookup itself failed (network, or β€” the common case for this repo β€” a private repo with no/invalid `GITHUB_TOKEN`, which GitHub reports as a 404 rather than 401/403 to avoid leaking the repo's existence to an unauthenticated caller); `error` carries the reason in that case. ```json { "current": "v0.25.0", "latest": "v0.25.0", "update_available": false, "checked": true, "error": null, "repo_url": "...", "releases_url": "..." } ``` --- ## Authentication See [AUTHENTICATION.md](AUTHENTICATION.md) for the full design (password + Authentik OIDC + Plex PIN flow, session model, recovery). ### `GET /api/auth/status` πŸ”“ What the login screen needs to decide what to show. ```json { "setup_required": false, "authenticated": true, "authentik_available": true, "plex_login_available": false } ``` ### `POST /api/auth/setup` πŸ”“ First-run only β€” refuses once a password already exists (`400`, use `change-password` instead). Body: `{ "password": "..." }` (min 8 chars). On success, also logs the new session in (same response shape as `login`). ### `POST /api/auth/login` πŸ”“ Body: `{ "password": "..." }`. `401` on wrong password. On success, sets the session cookie and returns `{ "ok": true }`. ### `POST /api/auth/logout` Clears the session (both server-side and the cookie). `{ "ok": true }`. ### `POST /api/auth/change-password` Body: `{ "current_password": "...", "new_password": "..." }` (new, min 8 chars). `400` if current password is wrong. Current session stays valid. ### `GET /api/auth/oidc/login` πŸ”“ Redirects (`303`) the browser to Authentik. `400` if Authentik isn't configured, or if Authentik itself can't be reached (discovery failure). ### `GET /api/auth/oidc/callback` πŸ”“ Authentik redirects here after approval/denial. Query: `code`, `state` (or `error` on denial). On success: sets the session cookie, redirects to `/`. `400` on a missing/stale state, a provider error, or ID-token verification failure. ### `GET /api/auth/plex/login` πŸ”“ Redirects to Plex's own auth page (`app.plex.tv/auth`) to start the PIN flow. `400` if the Plex PIN API can't be reached. ### `GET /api/auth/plex/callback` πŸ”“ Plex redirects here via its `forwardUrl`. Query: `pin_id`. Checks the PIN, resolves the account, and β€” **only if that account was previously linked** β€” grants a session and redirects to `/`. `400` otherwise (not completed/denied, or an unlinked account). ### `GET /api/settings/authentik` / `PUT` / `DELETE` Configure Authentik OIDC. `GET` never returns the stored secret, only whether one is set: ```json { "issuer_url": "https://auth.example.com/application/o/...", "client_id": "...", "secret_set": true, "configured": true } ``` `PUT` body: `{ "issuer_url": "...", "client_id": "...", "client_secret": "..." }` β€” `client_secret` optional (omit/blank to keep the existing one, same convention as the Plex token field). ### `GET /api/settings/bazarr` / `PUT` / `DELETE` Connect [Bazarr](https://www.bazarr.media/). `GET` never returns the API key, only whether one is set; it does return the secret for the webhook below: ```json { "configured": true, "url": "http://bazarr:6767", "key_set": true, "hook_token": "…", "languages": ["en", "fr"] } ``` `PUT` body `{ "url": "...", "api_key": "..." }` (`api_key` optional: blank keeps the saved one) saves, makes the webhook secret on first use, then tests the saved connection; the reply is the same object plus `"check": { "ok": true, "version": "1.5.0", "languages": [...] }` or `{ "ok": false, "error": "..." }`. `languages` are Bazarr's profile languages, which the indexer prefers when a video has several subtitle files. `DELETE` removes it. ### `POST /api/settings/bazarr/test` / `POST /api/settings/bazarr/rotate-hook` Re-test the saved connection (`{ "ok", "version" | "error" }`); make a new webhook secret (returns the settings object). ### `POST /api/hooks/bazarr/:token` πŸ”“ Bazarr's notification call (an Apprise `json://` URL pointing here). The secret in the path is the credential; a wrong one is `404`. The body is ignored. Answers `202` and, after about 20 seconds (calls close together share one), starts a library scan so the new subtitle is indexed. ### `GET /api/media/:rating_key/bazarr` Is this item in Bazarr? `{ "configured": false }`, or `{ "configured": true, "found": true, "target": { "kind": "movie"|"episode", "title": "...", "missing": [{ "code2": "en", "forced": false, "hi": false }] }, "sidecar": "Movie.en.srt", "can_sync": true }`. `found: false` when Bazarr doesn't have the file or more than one entry matches it. `502` if Bazarr can't be reached. ### `POST /api/media/:rating_key/bazarr/search` Ask Bazarr to search for the subtitles it is missing for this item (`{ "asked": , "target": {...}, "message": "..." }`; `asked: 0` means it isn't missing any). `404` if Bazarr doesn't have the file, `409` if Bazarr isn't set up, `502` if it can't be reached. ### `POST /api/media/:rating_key/bazarr/shift` Body `{ "offset_ms": -1500 }` (positive = later). Asks Bazarr (its "adjust times" action) to move every line of the `.srt` beside the video, then re-indexes the item: `{ "ok": true, "file": "Movie.en.srt", "reindexed": {...}, "message": "..." }`. `400` if there is no subtitle file beside the video; `502` if Bazarr refuses (it only changes `.srt` files, and the shift must be 1 ms to 10 minutes). ### `POST /api/media/:rating_key/bazarr/sync` Ask Bazarr to re-time the subtitle file beside the video (`400` if there is none). Bazarr works in the background; re-index the item afterwards. ### `GET /api/system/connection` How the app reads the connection this request came in on, for checking a reverse proxy or HTTPS setup: `{ "https", "host", "prefix", "app_url", "oidc_callback", "plex_callback", "peer", "client_ip", "forwarded_trusted", "public_url", "hsts_max_age", "headers": {...}, "warnings": [...] }`. `forwarded_trusted` says whether the `X-Forwarded-*` headers were believed (the sender is on the Trusted proxies list or a private address); `warnings` explains problems in plain words. ### `GET /api/settings/plex-login` / `DELETE` ```json { "linked": true, "username": "plexuser" } ``` `DELETE` unlinks (`{ "ok": true }`). ### `GET /api/settings/plex-login/link` Authenticated-only (unlike the other Plex-login routes above) β€” starts the same PIN flow but with `purpose=link`, so a successful completion updates the linked-account allowlist instead of granting a session. --- ## Search ### Search operators (`q`, for both searches below) `q` understands web-search-style operators. Whatever is left after them is the **text** to search for: dialogue in `/api/search`, titles in `/api/search/title`. | Operator | Meaning | |---|---| | `show:"The Lantern Cove"` | the show's name contains the value (any case) | | `ep:pilot` / `episode:pilot` | an **episode** whose title contains the value | | `movie:heist` | a **movie** whose title contains the value | | `ep:` / `movie:` (no value) | only episodes / only movies | | `library:Anime` / `lib:Anime` | in the library with exactly this name (any case) | | `season:2` | the season number | | `-` in front of any of them | the opposite: `-show:diner`, `-ep:"the pilot"`, `-movie:` | | `-word` / `-"a phrase"` | leave out dialogue lines (Dialogue) or titles and shows (Title) containing it | Rules: values may be `"quoted"` to hold spaces (an unclosed quote runs to the end); several operators combine with AND, and with the `library=` picker parameter; a `key:` that isn't an operator (`note: hello`, `12:30`) is plain text; `season:` needs a number or it is plain text; a leading `-` only counts when something follows it directly (`well - hello` is plain text); a quoted phrase is always text, so `"show:me the pie"` and `"-5 degrees"` search literally. `-show:x` keeps items that have no show (movies). The parser is `src/querysyntax.rs`. Responses add `"filters": ["show: The Lantern Cove", "not episode: pilot", "without β€œdale”"]` (how the query was read, for display). In Dialogue mode, a query with operators but **no dialogue text** (`show:"The Lantern Cove"`) returns the matching *items* (title-search shaped, no `text`) with `"listing": true`, instead of nothing. Exclusions alone can't be matched against dialogue, so they need text. ### `GET /api/search?q=&limit=&offset=&library=...` Dialogue search (SQLite FTS5 phrase match over subtitle text), best match first. Also returns any show names matching `q`, so a dialogue search that finds no dialogue still surfaces a matching show as an option. Results come in **pages**: `limit` is the page size (default 50, max 500) and `offset` how many matches to skip. `has_more` says whether more matches follow this page (found by asking for one extra row, so there is no slow count over the whole index); the UI shows "Showing the first 50 matches" and a **Show more** button. `library` limits the search to some libraries and may be repeated (`&library=TV&library=Movies`); leave it out, or leave it blank, for all libraries. `shows` is only filled on the first page (`offset=0`). ```json { "results": [ { "media_id": 1, "rating_key": "...", "kind": "episode", "title": "Tideline", "show": "The Lantern Cove", "season": 5, "episode": 14, "start_ms": 12345, "end_ms": 14200, "text": "..." } ], "shows": ["The Lantern Cove"], "has_more": true, "offset": 0 } ``` ### `GET /api/search/title?q=&limit=&offset=&library=...` Each result also has `has_subtitles` (whether any subtitle lines are indexed for it); the UI uses `false` to offer a "no subtitles" link to the Wanted tab. Title search (`LIKE` match against movie/episode/show title). Same `shows`, paging (`limit`, `offset`, `has_more`), `library` filter and operators as above. Operators matter most here for narrowing by show and episode title, e.g. `pilot show:diner` or `ep:"the gull" -show:lantern`. ```json { "results": [ { "rating_key": "...", "kind": "movie", "title": "Paper Moon Heist", "show": null, "season": null, "episode": null, "year": 2021, "duration_ms": 7800000 } ], "shows": [], "has_more": false, "offset": 0 } ``` --- ## Media, preview, export ### `GET /api/media/:rating_key` `200` with the `Media` row, or `404` if not indexed. ```json { "id": 1, "rating_key": "...", "kind": "episode", "title": "...", "show": "...", "season": 5, "episode": 14, "year": 2013, "file_path": "...", "part_key": null, "duration_ms": 2760000, "access_mode": "direct" } ``` ### `GET /api/media/:rating_key/subtitles?start=&end=` Indexed cues overlapping a window. `404` if the media isn't indexed. ```json { "cues": [{ "start_ms": 12000, "end_ms": 14000, "text": "..." }] } ``` ### `GET /api/media/:rating_key/video-info` The source video's frame rate, size and runtime, so the editor can count in frames. The rate is a fraction (`24000/1001` for "23.976") so frame numbers don't drift over a long film. `variable` is true when the file's frames aren't evenly spaced (frame numbers are then approximate). `404` if the media isn't indexed, `400` if its file isn't on a mounted volume. Probed once per file per run. ```json { "fps_num": 24000, "fps_den": 1001, "fps": 23.976, "width": 1920, "height": 1080, "variable": false, "duration_ms": 2760000 } ``` ### `GET /api/frame/:rating_key?t=&width=` One picture from the source file at `t` (milliseconds), as `image/jpeg`, `width` px wide (default 320, kept between 80 and 1280). `400` for a negative `t` or one past the end of the media. At most four frame/filmstrip renders run at once; others wait. ### `GET /api/filmstrip/:rating_key?start=&end=&count=&height=` `count` frames (default 12, kept between 2 and 40) evenly spread over `[start, end)`, side by side in one `image/jpeg`, each `height` px tall (default 60, kept between 24 and 160). The span may be at most 120 seconds (`400` beyond that). ### `POST /api/still` One frame as a picture, with its caption drawn on. It takes a moment, so it answers with the finished file rather than a job to poll. `400` if `t_ms` is negative or past the end of the media, the font is unknown, or the source isn't on a mounted volume; `404` if the media isn't indexed. Request: ```json { "rating_key": "12345", "t_ms": 62500, "format": "png", "width": 960, "quality": 90, "caption": { "mode": "source", "font": null, "font_scale": 100 }, "subtitle_offset_ms": 0, "filename": "the-pie-line" } ``` - `format`: `"png"`, `"jpg"` or `"gif"` (a one-frame GIF). - `width`: 960 by default, kept between 160 and 3840; the height follows. - `quality`: 1 to 100, JPEG only (default 90). - `caption`: the same object as in `/api/export`. With `mode: "source"` the line on screen at `t_ms` is drawn (the indexed lines, or `cues` if you send your own, moved by `subtitle_offset_ms`); `mode: "meme"` draws `top`/`bottom`, sized to the picture's width as it is in a 480 px clip. Omit it or use `"none"` for the bare frame. - `filename`: optional base name; the extension is set from `format`. ```json { "filename": "the-pie-line.png", "format": "png", "download_path": "/api/download/the-pie-line.png" } ``` The file is in the output folder like any export. Nothing from the source is written into it. Frame renders share the four slots of `/api/frame`. ### `GET /api/preview/:rating_key?start=&end=&width=&fps=&caption_mode=&top=&bottom=&font_size=&font=&font_scale=&offset_ms=&cues_json=` Returns `video/mp4` bytes β€” a short, fast-seeked, browser-safe render of just that window, with the same caption treatment the real export would use. Cached on disk keyed by every parameter that affects the rendered frames. `caption_mode` is `"none"` (default) | `"source"` | `"meme"`. `cues_json` (URL-encoded JSON array of `{start_ms,end_ms,text}`) lets the editor preview its own edited subtitle lines instead of re-fetching the indexed ones. `font` is a font's file name from `GET /api/fonts` (default: the default font; `400` if there is no such font) and `font_scale` the text size in percent (default 100, kept between 25 and 400). If `caption_mode=source` and no subtitle line falls inside the window, nothing is burned in. ### `POST /api/export` Starts a background export job, returns immediately. `400` if the window is invalid or over 60s, or the source isn't on a mounted volume. `404` if the media isn't indexed. Request (`ExportRequest`): ```json { "rating_key": "...", "start_ms": 12000, "end_ms": 18000, "format": "gif", "width": 480, "fps": 15, "quality": 80, "caption": { "mode": "source", "cues": null }, "subtitle_offset_ms": 0, "audio": false, "credit_source": true, "filename": "my-meme" } ``` - `format`: `"gif" | "webp" | "mp4" | "webm"`. - `caption`: `null` | `{"mode":"none"}` | `{"mode":"source","cues":null-or-[...]}` | `{"mode":"meme","top":"...","bottom":"...","font_size":48}`. `cues: null` under `"source"` means "burn whatever's indexed"; a real array means "burn exactly this instead" (the editor's user-edited lines). Both burned modes also take `"font"` (a file name from `GET /api/fonts`; absent or `null` is the default font) and `"font_scale"` (text size in percent, default 100, kept between 25 and 400). An unknown font is a `400`. When no subtitle line falls inside the clip, nothing is burned in. - `quality` drives libwebp's `-q:v` and x264/VP9 CRF for webp/mp4/webm, and (as of the gifsicle integration) a `gifsicle --lossy=N` post-process pass for GIF β€” ffmpeg's own palette pipeline for GIF has no quality lever of its own. A missing/misconfigured `gifsicle` binary skips that pass (logged, non-fatal) rather than failing the export; see `docs/DEPLOYMENT.md`'s `GIFSICLE_BIN`. - `audio`: keep the source audio track, encoded as Opus (`libopus`, 48kHz stereo, 128kbps). Only `mp4`/`webm` can carry it β€” GIF and WebP have no audio channel in their format spec. `400` if `audio: true` is combined with `format: "gif"` or `"webp"` (`export::validate_audio_format`). A source clip with no audio track of its own just exports silent; no error. - **Metadata.** An export never carries the source file's metadata: no global, per-stream or chapter tags (release name, comments, creation time, languages, track names), no subtitle or data tracks (captions are burned into the picture), and no ffmpeg/encoder version strings (`export::privacy_args`). The one tag that may be written is `title`: the movie (`Paper Moon Heist (2019)`) or show/episode (`The Lantern Cove - S02E03 - Tideline`) the clip came from, built from the library's metadata, not the file's. `credit_source` (default `true`) turns that title off; GIF and WebP have no place for a title and never get one. Response: `{ "job_id": "..." }` β€” poll `GET /api/jobs/:id` for progress. ### `POST /api/sequence/export` Same idea, multiple clips back to back (cap: 20 clips / 3 minutes total, validated before touching Plex/ffmpeg at all). ```json { "clips": [ { "rating_key": "...", "start_ms": 0, "end_ms": 3000, "caption": null, "subtitle_offset_ms": 0 } ], "format": "mp4", "width": 480, "fps": 15, "quality": 80, "audio": false, "credit_source": true, "filename": "sequence" } ``` `audio` is sequence-wide, same constraint as above β€” every clip in the sequence is either exported with sound or without, there's no per-clip toggle. A clip whose own source has no audio track gets silence substituted for its segment (so the concatenated result never has gaps or an inconsistent stream layout); see `src/export/`'s `sequence_clip_args_with_audio` for the mechanics. The same metadata rules apply, including to the intermediate files, so nothing from any clip's source survives into the result. With `credit_source`, the `title` lists each distinct source once, joined with ` / ` (cut short past 200 characters). Response: same `{ "job_id": "..." }` shape. ### `GET /api/fonts` The fonts a caption can use, built-in ones first, then uploaded ones: ```json { "fonts": [ { "id": "DejaVuSans.ttf", "family": "DejaVu Sans", "style": "Book", "builtin": true, "bytes": 757076 }, { "id": "Anton-Regular.ttf", "family": "Anton", "style": "Regular", "builtin": false, "bytes": 170812 } ], "default": { "id": "DejaVuSans.ttf", "family": "DejaVu Sans", "style": "Book", "builtin": true, "bytes": 757076 }, "max_bytes": 16777216, "max_fonts": 50 } ``` `id` is the file name and what `font` in a caption refers to. ### `POST /api/fonts?name=` Keeps an uploaded font. The body is the file's bytes (`Content-Type: application/octet-stream`). `201 {"font": {...}}`. `400` if the name isn't a plain `.ttf`/`.otf` file name (a folder in it is dropped) or the file isn't a usable font; `409` if a font with that name already exists (built in or uploaded) or the limit of 50 uploaded fonts is reached; `413` over 16 MB. ### `DELETE /api/fonts/:id` Deletes an uploaded font: `204`; `404` if there is none; `400` for a built-in font. ### `GET /api/jobs/:id` ```json { "id": "...", "status": "done", "format": "gif", "filename": "my-meme.gif", "audio": false, "download_path": "/api/download/my-meme.gif", "error": null } ``` `status`: `"queued" | "running" | "done" | "failed"`. Jobs are in-memory, scoped to the running process β€” they don't survive a restart (that's what `GET /api/exports`, below, is for). `audio` echoes the request's `audio` flag β€” the frontend uses it to decide whether the result preview's `