# HTTP API Documentation WatchState HTTP API reference. Examples use the default `/v1/api` prefix. --- ## Table of Contents - [HTTP API Documentation](#http-api-documentation) - [Table of Contents](#table-of-contents) - [Authentication](#authentication) - [Global Notes](#global-notes) - [Endpoints](#endpoints) - [Backends](#backends) - [GET /v1/api/backends](#get-v1apibackends) - [POST /v1/api/backends](#post-v1apibackends) - [GET /v1/api/backends/spec](#get-v1apibackendsspec) - [GET|POST /v1/api/backends/uuid/{type}](#getpost-v1apibackendsuuidtype) - [GET|POST /v1/api/backends/users/{type}](#getpost-v1apibackendsuserstype) - [GET|POST /v1/api/backends/discover/{type}](#getpost-v1apibackendsdiscovertype) - [POST /v1/api/backends/accesstoken/{type}](#post-v1apibackendsaccesstokentype) - [POST /v1/api/backends/plex/generate](#post-v1apibackendsplexgenerate) - [POST /v1/api/backends/plex/check](#post-v1apibackendsplexcheck) - [Configured Backend Endpoints](#configured-backend-endpoints) - [GET /v1/api/backend/{name}](#get-v1apibackendname) - [PUT /v1/api/backend/{name}](#put-v1apibackendname) - [PATCH /v1/api/backend/{name}](#patch-v1apibackendname) - [DELETE /v1/api/backend/{name}](#delete-v1apibackendname) - [GET /v1/api/backend/{name}/info](#get-v1apibackendnameinfo) - [GET /v1/api/backend/{name}/version](#get-v1apibackendnameversion) - [POST /v1/api/backend/{name}/webhook](#post-v1apibackendnamewebhook) - [GET /v1/api/backend/{name}/users](#get-v1apibackendnameusers) - [POST /v1/api/backend/{name}/accesstoken](#post-v1apibackendnameaccesstoken) - [GET /v1/api/backend/{name}/sessions](#get-v1apibackendnamesessions) - [GET /v1/api/backend/{name}/discover](#get-v1apibackendnamediscover) - [GET /v1/api/backend/{name}/library](#get-v1apibackendnamelibrary) - [POST|DELETE /v1/api/backend/{name}/library/{id}](#postdelete-v1apibackendnamelibraryid) - [GET|POST|PATCH|DELETE /v1/api/backend/{name}/option\[/{option}\]](#getpostpatchdelete-v1apibackendnameoptionoption) - [GET /v1/api/backend/{name}/search\[/{id}\]](#get-v1apibackendnamesearchid) - [GET /v1/api/backend/{name}/unmatched\[/{id}\]](#get-v1apibackendnameunmatchedid) - [GET /v1/api/backend/{name}/mismatched\[/{id}\]](#get-v1apibackendnamemismatchedid) - [GET /v1/api/backend/{name}/stale/{id}](#get-v1apibackendnamestaleid) - [DELETE /v1/api/backend/{name}/stale/{id}](#delete-v1apibackendnamestaleid) - [GET /v1/api/backend/{name}/ignore](#get-v1apibackendnameignore) - [POST /v1/api/backend/{name}/ignore](#post-v1apibackendnameignore) - [DELETE /v1/api/backend/{name}/ignore](#delete-v1apibackendnameignore) - [History](#history) - [GET /v1/api/history](#get-v1apihistory) - [GET /v1/api/history/options](#get-v1apihistoryoptions) - [POST /v1/api/history/export/{format}](#post-v1apihistoryexportformat) - [GET /v1/api/history/{id}](#get-v1apihistoryid) - [GET /v1/api/history/{id}/duplicates](#get-v1apihistoryidduplicates) - [DELETE /v1/api/history/{id}](#delete-v1apihistoryid) - [GET|POST|DELETE /v1/api/history/{id}/watch](#getpostdelete-v1apihistoryidwatch) - [GET /v1/api/history/{id}/validate](#get-v1apihistoryidvalidate) - [DELETE /v1/api/history/{id}/metadata/{backend}](#delete-v1apihistoryidmetadatabackend) - [GET /v1/api/history/{id}/images/{type}](#get-v1apihistoryidimagestype) - [Ignore Rules](#ignore-rules) - [GET /v1/api/ignore](#get-v1apiignore) - [POST /v1/api/ignore](#post-v1apiignore) - [DELETE /v1/api/ignore](#delete-v1apiignore) - [Logs](#logs) - [GET /v1/api/logs](#get-v1apilogs) - [GET /v1/api/logs/recent](#get-v1apilogsrecent) - [GET|DELETE /v1/api/log/{filename}](#getdelete-v1apilogfilename) - [Player Streaming](#player-streaming) - [GET /v1/api/player/playlist/{token}\[/{fake...}\]](#get-v1apiplayerplaylisttokenfake) - [GET /v1/api/player/m3u8/{token}\[/{fake...}\]](#get-v1apiplayerm3u8tokenfake) - [GET /v1/api/player/segments/{token}/{segment}\[.{type}\]](#get-v1apiplayersegmentstokensegmenttype) - [GET /v1/api/player/subtitle/{token}/{type}.{source}{index}.m3u8](#get-v1apiplayersubtitletokentypesourceindexm3u8) - [GET /v1/api/player/subtitle/{token}/{source}{index}.{ext}](#get-v1apiplayersubtitletokensourceindexext) - [State](#state) - [GET /v1/api/state/media-health](#get-v1apistatemedia-health) - [GET /v1/api/state/media-health/items](#get-v1apistatemedia-healthitems) - [POST /v1/api/state/media-health/run](#post-v1apistatemedia-healthrun) - [GET /v1/api/state/media-health/export/{format}](#get-v1apistatemedia-healthexportformat) - [GET /v1/api/state/backend-report](#get-v1apistatebackend-report) - [POST /v1/api/state/backend-report/run](#post-v1apistatebackend-reportrun) - [GET /v1/api/state/backend-report/export/{format}](#get-v1apistatebackend-reportexportformat) - [GET /v1/api/state/user-statistics](#get-v1apistateuser-statistics) - [POST /v1/api/state/user-statistics/run](#post-v1apistateuser-statisticsrun) - [GET /v1/api/state/user-statistics/export/{format}](#get-v1apistateuser-statisticsexportformat) - [System](#system) - [GET /v1/api/system/healthcheck](#get-v1apisystemhealthcheck) - [GET /v1/api/system/version](#get-v1apisystemversion) - [GET /v1/api/system/supported](#get-v1apisystemsupported) - [GET /v1/api/system/auth/test](#get-v1apisystemauthtest) - [GET /v1/api/system/auth/has\_user](#get-v1apisystemauthhas_user) - [GET /v1/api/system/auth/user](#get-v1apisystemauthuser) - [POST /v1/api/system/auth/signup](#post-v1apisystemauthsignup) - [POST /v1/api/system/auth/login](#post-v1apisystemauthlogin) - [POST /v1/api/system/auth/refresh](#post-v1apisystemauthrefresh) - [PUT /v1/api/system/auth/change\_password](#put-v1apisystemauthchange_password) - [DELETE /v1/api/system/auth/sessions](#delete-v1apisystemauthsessions) - [GET /v1/api/system/env](#get-v1apisystemenv) - [GET /v1/api/system/env/{key}](#get-v1apisystemenvkey) - [POST|DELETE /v1/api/system/env/{key}](#postdelete-v1apisystemenvkey) - [GET /v1/api/system/guids](#get-v1apisystemguids) - [GET /v1/api/system/guids/custom](#get-v1apisystemguidscustom) - [PUT /v1/api/system/guids/custom](#put-v1apisystemguidscustom) - [DELETE /v1/api/system/guids/custom/{id}](#delete-v1apisystemguidscustomid) - [GET /v1/api/system/guids/custom/{client}](#get-v1apisystemguidscustomclient) - [PUT /v1/api/system/guids/custom/{client}](#put-v1apisystemguidscustomclient) - [DELETE /v1/api/system/guids/custom/{client}/{id}](#delete-v1apisystemguidscustomclientid) - [GET /v1/api/system/guids/custom/{client}/{index}](#get-v1apisystemguidscustomclientindex) - [GET /v1/api/system/events](#get-v1apisystemevents) - [GET /v1/api/system/events/stats](#get-v1apisystemeventsstats) - [GET /v1/api/system/stats](#get-v1apisystemstats) - [GET /v1/api/system/transport/queue](#get-v1apisystemtransportqueue) - [GET /v1/api/system/transport/queue/{id}](#get-v1apisystemtransportqueueid) - [POST /v1/api/system/events](#post-v1apisystemevents) - [GET /v1/api/system/events/{id}](#get-v1apisystemeventsid) - [PATCH /v1/api/system/events/{id}](#patch-v1apisystemeventsid) - [DELETE /v1/api/system/events/{id}](#delete-v1apisystemeventsid) - [DELETE /v1/api/system/events](#delete-v1apisystemevents) - [POST /v1/api/system/command](#post-v1apisystemcommand) - [GET /v1/api/system/command](#get-v1apisystemcommand) - [GET /v1/api/system/command/{token}](#get-v1apisystemcommandtoken) - [DELETE /v1/api/system/command/{token}](#delete-v1apisystemcommandtoken) - [GET /v1/api/system/scheduler](#get-v1apisystemscheduler) - [POST /v1/api/system/scheduler/restart](#post-v1apisystemschedulerrestart) - [GET /v1/api/system/report](#get-v1apisystemreport) - [GET /v1/api/system/report/ini](#get-v1apisystemreportini) - [POST /v1/api/system/url/check](#post-v1apisystemurlcheck) - [POST /v1/api/system/yaml\[/{filename}\]](#post-v1apisystemyamlfilename) - [POST /v1/api/system/sign/{id}](#post-v1apisystemsignid) - [GET /v1/api/system/static/{file}](#get-v1apisystemstaticfile) - [GET /v1/api/system/images/{type}](#get-v1apisystemimagestype) - [GET /v1/api/system/backup](#get-v1apisystembackup) - [GET|DELETE /v1/api/system/backup/{filename}](#getdelete-v1apisystembackupfilename) - [GET /v1/api/system/processes](#get-v1apisystemprocesses) - [DELETE /v1/api/system/processes/{id}](#delete-v1apisystemprocessesid) - [DELETE /v1/api/system/cache](#delete-v1apisystemcache) - [DELETE /v1/api/system/reset](#delete-v1apisystemreset) - [POST /v1/api/system/reset/opcache](#post-v1apisystemresetopcache) - [GET /v1/api/system/suppressor](#get-v1apisystemsuppressor) - [POST /v1/api/system/suppressor](#post-v1apisystemsuppressor) - [GET /v1/api/system/suppressor/{id}](#get-v1apisystemsuppressorid) - [PUT /v1/api/system/suppressor/{id}](#put-v1apisystemsuppressorid) - [DELETE /v1/api/system/suppressor/{id}](#delete-v1apisystemsuppressorid) - [Tasks](#tasks) - [GET /v1/api/tasks](#get-v1apitasks) - [GET /v1/api/tasks/{id}](#get-v1apitasksid) - [GET|POST|DELETE /v1/api/tasks/{id}/queue](#getpostdelete-v1apitasksidqueue) - [Prune](#prune) - [GET /v1/api/prune](#get-v1apiprune) - [Identities](#identities) - [GET /v1/api/identities](#get-v1apiidentities) - [POST /v1/api/identities](#post-v1apiidentities) - [DELETE /v1/api/identities/{identity}](#delete-v1apiidentitiesidentity) - [GET /v1/api/identities/{identity}](#get-v1apiidentitiesidentity) - [PUT /v1/api/identities/{identity}](#put-v1apiidentitiesidentity) - [GET /v1/api/identities/provision](#get-v1apiidentitiesprovision) - [PUT /v1/api/identities/provision/mapping](#put-v1apiidentitiesprovisionmapping) - [POST /v1/api/identities/provision](#post-v1apiidentitiesprovision) - [POST /v1/api/identities/provision/sync-backends](#post-v1apiidentitiesprovisionsync-backends) - [Webhook](#webhook) - [POST|PUT /v1/api/webhook](#postput-v1apiwebhook) - [Error Responses](#error-responses) --- ## Authentication Most routes require either an API key or a signed user token. 1. API key in a header: ``` X-APIKEY: ``` 2. API key in the query string: ``` ?apikey= ``` 3. Signed user token in the `Authorization` header for most authenticated routes: ``` Authorization: Bearer ``` or ``` Authorization: Token ``` 4. Signed user token in the query string: ``` ?ws_token= ``` --- ## Global Notes - **Content-Type** - Send `Content-Type: application/json` for JSON request bodies. - JSON auto-parsing only happens for `application/json` and `application/*+json`. - **Identity Context** - Many endpoints operate on a per-identity config/database. - Those routes accept `X-User: ` or `?user=`. - If omitted, WatchState uses the `main` identity context. - **Response Format** - Successful endpoints usually return a JSON object or JSON array. - Error responses use: ```json { "error": { "code": 400, "message": "Description of the problem" } } ``` - Informational success messages use: ```json { "info": { "code": 200, "message": "Human readable message" } } ``` - **Pagination** - Most paginated endpoints use `page` and `perpage`. - History, Media Health, and events responses include paging metadata. - **Raw Backend Responses** - Several backend endpoints accept `raw=true`. - `raw=true` exposes backend-specific upstream payloads and can be much larger than the normalized response. - **Real-time APIs** - Real-time endpoints use Server-Sent Events (SSE), mainly for logs and command execution. --- ## Endpoints ### Backends `GET /v1/api/backends` and `POST /v1/api/backends` honor `X-User` or `?user=`. Probe routes work without a saved backend. #### GET /v1/api/backends Lists configured backends for the current user. **Response**: ```json [ { "name": "plex_main", "type": "plex", "url": "https://plex.example.com", "uuid": "...", "user": "owner", "import": { "enabled": true, "lastSync": "2026-03-28T12:00:00+00:00" }, "export": { "enabled": false, "lastSync": null }, "urls": { "webhook": "/v1/api/webhook?apikey=..." } } ] ``` **Notes**: - External responses omit the stored `options` object. - The generated webhook URL includes `?apikey=...` when secure API mode is enabled. --- #### POST /v1/api/backends Creates and persists a new backend definition. **Body**: ```json { "name": "plex_main", "type": "plex", "url": "https://plex.example.com", "token": "secret-token", "user": "owner", "uuid": "optional-server-id", "import": { "enabled": true }, "export": { "enabled": false }, "options": { "client": { "verify_host": true } } } ``` **Response**: ```json { "name": "plex_main", "type": "plex", "url": "https://plex.example.com", "uuid": "...", "token": "secret-token", "...": "saved backend fields" } ``` **Errors**: - `400 Bad Request` if `type`, `name`, `url`, or `token` is missing or invalid. - `404 Not Found` if the user does not exist. - `409 Conflict` if the backend name already exists. **Notes**: - Backend names must use lowercase letters, numbers, and underscores only. - If `uuid` is omitted, WatchState tries to fetch it from the remote backend. - Only option keys defined in `config/servers.spec.php` are stored. --- #### GET /v1/api/backends/spec Returns the backend option specification. **Response**: ```json [ { "key": "options.client.timeout", "type": "float", "description": "HTTP timeout in seconds" } ] ``` **Notes**: - The response is generated from `config/servers.spec.php`. - `choices` is included for enumerated fields when the spec defines it. --- #### GET|POST /v1/api/backends/uuid/{type} Probes an arbitrary backend connection and returns its type plus unique identifier. **Path**: - `type`: Backend type such as `plex`, `jellyfin`, or `emby`. **Input**: - Query parameters for `GET`, or JSON body for `POST`. - Required fields: `url`, `token` - Optional fields: `uuid`, `user`, and selected `options.*` **Response**: ```json { "type": "plex", "identifier": "..." } ``` **Errors**: - `400 Bad Request` if the backend type, URL, or token is invalid. - `500 Internal Server Error` if the remote probe fails. --- #### GET|POST /v1/api/backends/users/{type} Returns users from an arbitrary backend connection without saving it first. **Path**: - `type`: Backend type. **Input**: - Query parameters for `GET`, or JSON body for `POST`. - Required fields: `url`, `token` - Optional fields: - `tokens` - Include backend-specific user tokens when supported. - `target_user` - Narrow the result to a specific backend user. - `no_cache` - Force a fresh fetch. **Response**: ```json [ { "id": "...", "name": "...", "...": "backend user fields" } ] ``` **Errors**: - `400 Bad Request` if the backend type, URL, or token is invalid. - `500 Internal Server Error` if the remote backend request fails. --- #### GET|POST /v1/api/backends/discover/{type} Discovers available Plex servers for an arbitrary Plex connection. **Path**: - `type`: Must be `plex`. **Input**: - Query parameters for `GET`, or JSON body for `POST`. - Required fields: `url`, `token` - Optional fields: - `options.ADMIN_TOKEN` - Plex admin token used during discovery. **Response**: ```json [ { "name": "...", "uri": "...", "...": "Plex discovery fields" } ] ``` **Errors**: - `400 Bad Request` if the backend type is not `plex`, or if required connection data is missing. - `500 Internal Server Error` if discovery fails. --- #### POST /v1/api/backends/accesstoken/{type} Generates a Jellyfin or Emby access token using username/password credentials. **Path**: - `type`: Must be `jellyfin` or `emby`. **Body**: ```json { "url": "https://jellyfin.example.com", "username": "alice", "password": "secret" } ``` **Response**: ```json { "AccessToken": "...", "...": "backend-specific token payload" } ``` **Errors**: - `400 Bad Request` if credentials are missing or the backend type is unsupported. - `500 Internal Server Error` if token generation fails. --- #### POST /v1/api/backends/plex/generate Starts the Plex PIN flow used for browser/device login. **Access**: - Open. **Response**: ```json { "id": 123456, "code": "ABCD", "...": "Plex pin payload" } ``` **Errors**: - Returns the upstream Plex status when the PIN request fails. **Notes**: - The response also includes the WatchState Plex client headers used for the request. --- #### POST /v1/api/backends/plex/check Polls the Plex PIN flow and returns the current PIN state. **Access**: - Open. **Body**: ```json { "id": 123456, "code": "ABCD" } ``` **Response**: ```json { "id": 123456, "authToken": "...", "...": "Plex pin status fields" } ``` **Errors**: - `400 Bad Request` if `id` or `code` is missing. - Returns the upstream Plex status when the check fails. --- ### Configured Backend Endpoints These routes operate on a saved backend name and honor `X-User` or `?user=`. #### GET /v1/api/backend/{name} Returns a single saved backend definition. **Path**: - `name`: Saved backend name. **Response**: ```json { "name": "plex_main", "type": "plex", "url": "https://plex.example.com", "token": "secret-token", "uuid": "...", "...": "backend fields" } ``` **Errors**: - `404 Not Found` if the user or backend does not exist. **Notes**: - This endpoint returns the stored backend object and is not redacted like `GET /v1/api/backends`. --- #### PUT /v1/api/backend/{name} Replaces a saved backend configuration and revalidates it. **Path**: - `name`: Saved backend name. **Body**: ```json { "url": "https://plex.example.com", "token": "secret-token", "user": "owner", "uuid": "...", "import": { "enabled": true }, "export": { "enabled": false }, "options": { "client": { "timeout": 30 } } } ``` **Response**: ```json { "name": "plex_main", "type": "plex", "...": "updated backend fields" } ``` **Errors**: - `400 Bad Request` if validation fails. - `404 Not Found` if the user or backend does not exist. **Notes**: - Removed legacy keys are stripped automatically before the config is persisted. - Backends with `import.enabled=false` now run in metadata-only mode. --- #### PATCH /v1/api/backend/{name} Partially updates a saved backend using a raw JSON patch list. **Path**: - `name`: Saved backend name. **Body**: ```json [ { "key": "options.client.timeout", "value": 30 }, { "key": "import.enabled", "value": true } ] ``` **Response**: ```json { "name": "plex_main", "type": "plex", "...": "updated backend fields" } ``` **Errors**: - `400 Bad Request` if the body is not valid JSON, if a key is missing, if a key is immutable, or if a value fails validation. - `404 Not Found` if the user or backend does not exist. **Notes**: - The body must be a JSON array, not an object. - Immutable keys include `name`, `type`, `options`, `import`, `export`, and removed legacy keys. - This route validates fields against the server spec but does not perform the same remote context validation as `PUT`. --- #### DELETE /v1/api/backend/{name} Deletes a backend definition and removes its metadata references from history. **Path**: - `name`: Saved backend name. **Response**: ```json { "deleted": { "references": 42, "records": 7 }, "backend": { "name": "plex_main", "...": "deleted backend fields" } } ``` **Errors**: - `404 Not Found` if the user or backend does not exist. **Notes**: - Metadata and extra blocks for the backend are removed from the `state` table before the backend config is deleted. - Records with no remaining metadata are deleted. --- #### GET /v1/api/backend/{name}/info Returns backend info and capabilities. **Path**: - `name`: Saved backend name. **Query**: - `raw` (optional) - Return the backend's raw response. **Response**: ```json { "...": "backend info payload" } ``` **Errors**: - `404 Not Found` if the user or backend does not exist. - `500 Internal Server Error` if the backend request fails. --- #### GET /v1/api/backend/{name}/version Returns the backend server version. **Path**: - `name`: Saved backend name. **Response**: ```json { "version": "..." } ``` **Errors**: - `404 Not Found` if the user or backend does not exist. - `500 Internal Server Error` if the backend request fails. --- #### POST /v1/api/backend/{name}/webhook Registers or updates the WatchState webhook URL on the remote backend. **Path**: - `name`: Saved backend name. **Body**: ```json { "webhook_url": "https://your-ws.example.com/v1/api/webhook" } ``` **Response**: ```json { "message": "Webhook configured successfully." } ``` **Errors**: - `400 Bad Request` if `webhook_url` is missing or empty. - `404 Not Found` if the user or backend does not exist. - `503 Service Unavailable` if the backend rejected the webhook registration. - `500 Internal Server Error` if an unexpected error occurs. **Notes**: - If a webhook with the same URL already exists on the backend, the endpoint updates it instead of creating a duplicate. - For Emby, a Emby Premiere is required. - For Jellyfin, the Webhook plugin must be installed. - For Plex, a Plex Pass subscription is required. --- #### GET /v1/api/backend/{name}/users Returns users from a saved backend connection. **Path**: - `name`: Saved backend name. **Query**: - `tokens` (optional) - Include backend-specific tokens when supported. - `target_user` (optional) - Return data for a single backend user. - `raw` (optional) - Include the backend raw response. **Response**: ```json [ { "id": "...", "name": "...", "...": "backend user fields" } ] ``` **Errors**: - `404 Not Found` if the user or backend does not exist. - `500 Internal Server Error` if the backend request fails. --- #### POST /v1/api/backend/{name}/accesstoken Generates a per-user token from a saved backend. **Path**: - `name`: Saved backend name. **Body**: ```json { "id": "backend-user-id", "username": "optional-username" } ``` **Response**: ```json { "token": "...", "username": "optional-username" } ``` **Errors**: - `400 Bad Request` if `id` is missing. - `404 Not Found` if the user or backend does not exist. - `500 Internal Server Error` if token generation fails. --- #### GET /v1/api/backend/{name}/sessions Returns active sessions from a saved backend. **Path**: - `name`: Saved backend name. **Query**: - `raw` (optional) - Include the backend raw response. **Response**: ```json [ { "id": "...", "user": "...", "...": "session fields" } ] ``` **Errors**: - `404 Not Found` if the user or backend does not exist. - `500 Internal Server Error` if the backend request fails. --- #### GET /v1/api/backend/{name}/discover Discovers Plex servers using a saved Plex backend configuration. **Path**: - `name`: Saved backend name. **Response**: ```json [ { "name": "...", "uri": "...", "...": "Plex discovery fields" } ] ``` **Errors**: - `400 Bad Request` if the backend is not Plex. - `404 Not Found` if the user or backend does not exist. - `500 Internal Server Error` if discovery fails. --- #### GET /v1/api/backend/{name}/library Lists libraries exposed by a saved backend. **Path**: - `name`: Saved backend name. **Response**: ```json [ { "id": "1", "name": "Movies", "supported": true, "ignored": false } ] ``` **Errors**: - `404 Not Found` if the user or backend does not exist. - `500 Internal Server Error` if the backend request fails. --- #### POST|DELETE /v1/api/backend/{name}/library/{id} Marks or un-marks a library as ignored in backend config. **Path**: - `name`: Saved backend name. - `id`: Library identifier. **Method Behavior**: - `POST` marks the library as ignored. - `DELETE` removes the ignore flag. **Response**: ```json [ { "id": "1", "name": "Movies", "ignored": true } ] ``` **Errors**: - `404 Not Found` if the user, backend, or library does not exist. - `409 Conflict` if the library is already in the requested state. **Notes**: - Ignored library IDs are persisted in `options.ignore`. --- #### GET|POST|PATCH|DELETE /v1/api/backend/{name}/option[/{option}] Gets, sets, or deletes a single backend option. **Path**: - `name`: Saved backend name. - `option` (optional) - Option key. You can also send it as `key`. **Method Behavior**: - `GET` reads an option. - `POST` and `PATCH` set an option value. - `DELETE` removes the stored option value. **Input**: - `GET`: option key in the path or query string. - `POST`, `PATCH`, `DELETE`: JSON body or form fields with `key` and optional `value`. **Response**: ```json { "key": "options.client.timeout", "value": 30, "real_val": "30", "type": "float", "description": "HTTP timeout in seconds" } ``` **Errors**: - `400 Bad Request` if the key is missing, invalid, outside the allowed namespace, or if validation fails. - `404 Not Found` if the user, backend, or option does not exist. **Notes**: - External callers may only manage keys that start with `options.`. - Internal requests can access non-`options.` keys. - Boolean parsing accepts common values such as `true`, `false`, `on`, `off`, `yes`, and `no`. --- #### GET /v1/api/backend/{name}/search[/{id}] Searches backend content by backend item ID or free-text query. **Path**: - `name`: Saved backend name. - `id` (optional) - Backend item ID. **Query**: - `id` (optional) - Alternative way to pass the backend item ID. - `q` (optional) - Free-text search query. - `limit` (optional) - Maximum number of results. Defaults to `25`. - `raw` (optional) - Include raw backend payloads. **Response**: ```json [ { "id": 123, "title": "Movie Title", "type": "movie", "via": "plex_main", "webUrl": "...", "...": "normalized entity fields" } ] ``` **Errors**: - `400 Bad Request` if neither `id` nor `q` is provided. - `404 Not Found` if the user or backend does not exist, or if no results are found. - `500 Internal Server Error` if the backend request fails. **Notes**: - Results are normalized through the shared entity formatter. - When `raw=true`, each item also includes the backend raw response. --- #### GET /v1/api/backend/{name}/unmatched[/{id}] Scans one library, or all supported non-ignored libraries, for items without supported GUIDs. **Path**: - `name`: Saved backend name. - `id` (optional) - Library ID. If omitted, WatchState scans every supported non-ignored library. **Query**: - `timeout` (optional) - Override backend timeout. - `raw` (optional) - Include raw backend payloads. **Response**: ```json [ { "title": "Unknown Item", "type": "movie", "webUrl": "...", "library": "1", "path": "/media/movies/Unknown Item.mkv" } ] ``` **Errors**: - `404 Not Found` if the user or backend does not exist. - `500 Internal Server Error` if the scan fails. --- #### GET /v1/api/backend/{name}/mismatched[/{id}] Scans one library, or all supported non-ignored libraries, for likely bad title/path matches. **Path**: - `name`: Saved backend name. - `id` (optional) - Library ID. If omitted, WatchState scans every supported non-ignored library. **Query**: - `timeout` (optional) - Override backend timeout. - `raw` (optional) - Include raw backend payloads. - `percentage` (optional) - Threshold below which a result is returned. Defaults to `50`. - `method` (optional) - `similarity` or `levenshtein`. Defaults to `similarity`. **Response**: ```json [ { "title": "Movie Title", "percent": 32.7, "matches": [ { "path": "movie title 2024", "title": "movie title", "methods": { "similarity": 32.7, "levenshtein": 88.1, "startWith": false } } ], "webUrl": "...", "library": "1" } ] ``` **Errors**: - `400 Bad Request` if `method` is invalid. - `404 Not Found` if the user or backend does not exist. - `500 Internal Server Error` if the scan fails. --- #### GET /v1/api/backend/{name}/stale/{id} Compares local mapped records against one remote library and reports stale local references. **Path**: - `name`: Saved backend name. - `id`: Library ID. **Query**: - `ignore` (optional) - Ignore the cached remote library snapshot and rebuild it. - `timeout` (optional) - Override backend timeout. **Response**: ```json { "backend": { "name": "plex_main", "library": { "id": "1", "name": "Movies" } }, "counts": { "remote": 1200, "local": 1220, "stale": 20 }, "items": [ { "id": 101, "title": "Old Record", "...": "normalized entity fields" } ] } ``` **Errors**: - `400 Bad Request` if `id` is empty. - `404 Not Found` if the user or backend does not exist. --- #### DELETE /v1/api/backend/{name}/stale/{id} Accepts a list of stale IDs to remove from the mapper workflow. **Path**: - `name`: Saved backend name. - `id`: Library ID. **Body**: ```json { "ids": [101, 102, 103] } ``` **Response**: ```json { "info": { "code": 200, "message": "Removed stale references." } } ``` **Errors**: - `400 Bad Request` if `id` is empty or if `ids` is missing or empty. - `404 Not Found` if the user or backend does not exist. **Notes**: - The current implementation validates the request and loads mapper data, but does not yet delete the supplied IDs from storage. --- #### GET /v1/api/backend/{name}/ignore Lists ignore rules that are scoped to one backend. **Path**: - `name`: Saved backend name. **Response**: ```json [ { "rule": "movie://tmdb:123@plex_main", "type": "Movie", "backend": "plex_main", "db": "tmdb", "id": "123", "scoped": "No", "created": "2026-03-28T12:00:00+00:00" } ] ``` **Errors**: - `404 Not Found` if the user or backend does not exist. --- #### POST /v1/api/backend/{name}/ignore Adds a backend-scoped ignore rule. **Path**: - `name`: Saved backend name. **Body**: ```json { "type": "movie", "db": "tmdb", "id": "123" } ``` **Alternative Body**: ```json { "rule": "movie://tmdb:123@plex_main" } ``` **Response**: - `201 Created` with an empty body. **Errors**: - `400 Bad Request` if required parts are missing or the rule is invalid. - `404 Not Found` if the user or backend does not exist. - `409 Conflict` if the exact rule already exists or if a global rule already exists. **Notes**: - If `scoped` is provided, the rule becomes `...?id=`. --- #### DELETE /v1/api/backend/{name}/ignore Removes a backend-scoped ignore rule. **Path**: - `name`: Saved backend name. **Body**: ```json { "rule": "movie://tmdb:123@plex_main" } ``` **Response**: - `200 OK` with an empty body. **Errors**: - `400 Bad Request` if `rule` is missing or invalid. - `404 Not Found` if the user, backend, or rule does not exist. --- ### History All history routes honor `X-User` or `?user=`. #### GET /v1/api/history Searches and paginates the local history database. **Query**: - Pagination: - `page` (optional) - Defaults to `1`. - `perpage` (optional) - Defaults to `12`. - Output shaping: - `view` (optional) - Comma-separated field list. Only the requested fields are returned for each item. - `with_duplicates` (optional) - Include duplicate reference IDs. - Sorting: - `sort` (optional, repeatable or array-style) - Sort expressions such as `updated_at:desc`. - Filters: - `watched` - `in_progress` - `status` (`watched`, `in_progress`, or `not_started`) - `id` - `via` - `year` - `year_min`, `year_max` (inclusive) - `type` (`movie` or `episode`) - `title` - `season` - `episode` - `parent` in `provider://id` format - `guids` in `provider://id` format - `rguid` in `guid://parentID/seasonNumber[/episodeNumber]` format - `metadata` with companion `key`, `value`, and optional `exact` - `extra` with companion `key`, `value`, and optional `exact` - `path` - `subtitle` - `genres` (string or array of up to 100 choices; array values match any selected genre) - `library` (string or array of up to 100 `backend://libraryID` values) **Response**: ```json { "paging": { "total": 25, "perpage": 12, "current_page": 1, "first_page": 1, "next_page": 2, "prev_page": null, "last_page": 3 }, "filters": { "watched": 1 }, "history": [ { "id": 101, "title": "Movie Title", "type": "movie", "via": "plex_main", "watched": 1, "webUrl": "...", "reported_by": ["plex_main", "jellyfin_main"], "not_reported_by": [], "duplicate_reference_ids": [] } ], "links": { "self": "/v1/api/history?page=1", "first_url": "/v1/api/history?page=1", "next_url": "/v1/api/history?page=2", "prev_url": null, "last_url": "/v1/api/history?page=3" }, "searchable": [ { "key": "title", "description": "Search using the title.", "type": "string" } ] } ``` **Errors**: - `400 Bad Request` for invalid `rguid`, `parent`, `guids`, or JSON field query syntax. - `404 Not Found` if the user does not exist or if no results match. **Notes**: - `metadata`, `extra`, `path`, `subtitle`, and `genres` searches can be slow. - The normalized item format includes extra fields such as `content_path`, `content_title`, `content_overview`, `content_genres`, `reported_by`, `not_reported_by`, and `isTainted`. - Filters on different fields are combined before counting and pagination. --- #### GET /v1/api/history/options Returns genre and library choices for the selected identity's history. **Response**: ```json { "genres": ["comedy", "drama"], "libraries": [ { "value": "plex_main://1", "label": "plex_main / Movies" } ] } ``` --- #### POST /v1/api/history/export/{format} Downloads matching or selected history records as a title list or individual records. **Path**: - `format` is `json` or `markdown`. **Body**: ```json { "filters": { "status": "watched", "genres": ["drama"], "library": ["plex_main://1"] }, "group": "titles" } ``` **Response**: - A `.json` or `.md` attachment. - JSON contains `version`, `count`, `grouping`, and `items`. **Notes**: - `filters` accepts the same search filters as `GET /history`. Pagination does not limit exports. - Supply `ids` as an array of positive record IDs to export a selection within those filters. Omit it to export all matches. - `group` is `titles` for movies and grouped shows, or `records` for individual movies and episodes. - Grouped show progress counts all episodes in the user's history, not only the selected episodes. - Exported items exclude internal record IDs, paths, server URLs, and credentials. **Errors**: - `400 Bad Request` for invalid filters, grouping, or an empty or invalid `ids` array. - `404 Not Found` if no records match. --- #### GET /v1/api/history/{id} Returns one local history record. **Path**: - `id`: Numeric local record ID. **Query**: - `files` (optional) - Include media file probes and sidecar subtitles. - `with_duplicates` (optional) - Include duplicate reference IDs. **Response**: ```json { "id": 101, "title": "Movie Title", "type": "movie", "via": "plex_main", "content_path": "/media/movies/Movie Title (2024).mkv", "content_exists": true, "duplicate_reference_ids": [], "files": [ { "path": "/media/movies/Movie Title (2024).mkv", "source": ["plex_main"], "ffprobe": { "streams": [], "format": {} }, "subtitles": ["/media/movies/Movie Title (2024).en.srt"] } ], "hardware": { "codecs": [ { "codec": "libx264", "name": "H.264 (CPU) (All)", "hwaccel": false } ], "devices": ["/dev/dri/renderD128"] } } ``` **Errors**: - `404 Not Found` if the user or item does not exist. **Notes**: - `files=1` performs filesystem checks and `ffprobe` calls, so it is heavier than a normal read. --- #### GET /v1/api/history/{id}/duplicates Returns duplicate local history IDs for the record. **Path**: - `id`: Numeric local record ID. **Response**: ```json { "duplicate_reference_ids": [102, 103] } ``` **Errors**: - `404 Not Found` if the user or item does not exist. - `500 Internal Server Error` if duplicate lookup fails. --- #### DELETE /v1/api/history/{id} Deletes one local history record. **Path**: - `id`: Numeric local record ID. **Response**: - `200 OK` with an empty body. **Errors**: - `404 Not Found` if the user or item does not exist. --- #### GET|POST|DELETE /v1/api/history/{id}/watch Reads or changes the watched state of a history record. **Path**: - `id`: Numeric local record ID. **Method Behavior**: - `GET` returns the current watched flag. - `POST` marks the item as watched and queues a sync push. - `DELETE` marks the item as unwatched and queues a sync push. **GET Response**: ```json { "watched": true } ``` **POST or DELETE Response**: - Returns the same payload as `GET /v1/api/history/{id}` for the updated record. **Errors**: - `404 Not Found` if the user or item does not exist. - `409 Conflict` if the item is already in the requested watched state. **Notes**: - The route records a `webui.markplayed` or `webui.markunplayed` event in the entity extra data. --- #### GET /v1/api/history/{id}/validate Validates that each backend reference for a local record still exists remotely. **Path**: - `id`: Numeric local record ID. **Response**: ```json { "plex_main": { "id": "12345", "status": true, "message": "Item found." }, "jellyfin_main": { "id": "67890", "status": false, "message": "Item not found." } } ``` **Errors**: - `404 Not Found` if the user or item does not exist. **Notes**: - The response includes `X-Cache: HIT` or `X-Cache: MISS`. - Results are cached for 10 minutes. --- #### DELETE /v1/api/history/{id}/metadata/{backend} Removes one backend metadata block from a local history record. **Path**: - `id`: Numeric local record ID. - `backend`: Backend name. **Response**: - Returns the updated record payload, or: ```json { "info": { "code": 200, "message": "Record deleted." } } ``` **Errors**: - `404 Not Found` if the user, item, or backend metadata block does not exist. **Notes**: - If the removed metadata block was the last one on the record, the entire local record is deleted. --- #### GET /v1/api/history/{id}/images/{type} Proxies a poster or background image for a history record. **Path**: - `id`: Numeric local record ID. - `type`: `poster` or `background` **Response**: - Binary image stream with headers such as `Content-Type` and `X-Via`. **Errors**: - `304 Not Modified` if `If-Modified-Since` is present. - `400 Bad Request` if image fetching fails. - `404 Not Found` if the user, item, remote item, or requested image is unavailable. **Notes**: - Images are fetched from the item's `via` backend only. --- ### Ignore Rules All ignore-rule routes honor `X-User` or `?user=`. #### GET /v1/api/ignore Lists ignore rules for the current user. **Query**: - `type` (optional) - `db` (optional) - `id` (optional) - `backend` (optional) **Response**: ```json [ { "rule": "movie://tmdb:123@plex_main", "id": "123", "type": "Movie", "backend": "plex_main", "db": "tmdb", "title": "Movie Title", "scoped": false, "scoped_to": null, "created": "2026-03-28T12:00:00+00:00" } ] ``` **Errors**: - `404 Not Found` if the user does not exist. --- #### POST /v1/api/ignore Adds an ignore rule. **Body**: ```json { "rule": "movie://tmdb:123@plex_main" } ``` **Alternative Body**: ```json { "id": "123", "db": "tmdb", "backend": "plex_main", "type": "movie", "scoped": true, "scoped_to": 101 } ``` **Response**: ```json { "rule": "movie://tmdb:123@plex_main?id=101", "id": "123", "type": "Movie", "backend": "plex_main", "db": "tmdb", "title": "Movie Title", "scoped": true, "scoped_to": 101, "created": "2026-03-28T12:00:00+00:00" } ``` **Errors**: - `400 Bad Request` if required fields are missing or the rule is invalid. - `404 Not Found` if the user does not exist. - `409 Conflict` if the rule already exists. --- #### DELETE /v1/api/ignore Removes an ignore rule. **Body**: ```json { "rule": "movie://tmdb:123@plex_main" } ``` **Response**: ```json { "rule": "movie://tmdb:123@plex_main", "id": "123", "type": "Movie", "backend": "plex_main", "db": "tmdb", "title": "Movie Title", "scoped": false, "scoped_to": null } ``` **Errors**: - `400 Bad Request` if `rule` is missing or invalid. - `404 Not Found` if the user does not exist or the rule cannot be found. --- ### Logs #### GET /v1/api/logs Lists log, webhook dump, and debug files under WatchState's temp directories. **Response**: ```json [ { "filename": "access.20260328.log", "type": "access", "date": "20260328", "size": 12345, "modified": "2026-03-28T12:00:00+00:00" } ] ``` --- #### GET /v1/api/logs/recent Returns the most recent lines from today's `.log` files. **Query**: - `limit` (optional) - Defaults to `50`. **Response**: ```json [ { "filename": "access.20260328.log", "type": "access", "date": "20260328", "size": 12345, "modified": "2026-03-28T12:00:00+00:00", "lines": [ { "id": "...", "item_id": "101", "user": "main", "backend": "plex_main", "date": "2026-03-28T12:00:00+00:00", "text": "Queuing main@plex_main request ..." } ] } ] ``` **Errors**: - `500 Internal Server Error` if the log path is not configured. **Notes**: - Only today's `.log` files are returned, not older logs or JSON dump files. --- #### GET|DELETE /v1/api/log/{filename} Reads, downloads, streams, or deletes a single log, debug, or webhook file. **Path**: - `filename`: File name returned by `GET /v1/api/logs`. **Query Parameters for GET**: - `download` (optional) - Download the raw file. - `stream` (optional) - Stream the file over SSE. - `offset` (optional) - Reverse-pagination offset from the end of the file. **Normal GET Response**: ```json { "filename": "access.20260328.log", "offset": 100, "next": 200, "max": 500, "type": "log", "lines": [ { "id": "...", "item_id": null, "user": "main", "backend": "plex_main", "date": "2026-03-28T12:00:00+00:00", "text": "Some log line" } ] } ``` **Stream Response**: - `Content-Type: text/event-stream; charset=UTF-8` - Emits: - `data` events containing JSON formatted log lines - `ping` keepalive events **DELETE Response**: - `200 OK` with an empty body. **Errors**: - `404 Not Found` if the file does not exist. **Notes**: - Path traversal is blocked with `realpath` and base path checks. - `download=1` returns the raw file stream instead of JSON. - In practice, bad or unresolvable file names usually surface as `404 Not Found`; `400 Bad Request` only applies when the route argument itself is missing. --- ### Player Streaming All player routes are open. Access is gated by the playback token in the path. Tokens are created with `POST /v1/api/system/sign/{id}`. #### GET /v1/api/player/playlist/{token}[/{fake...}] Builds the top-level HLS playlist for a signed media file. **Path**: - `token`: Short-lived playback token. **Query**: - `debug` (optional) - Enable verbose debug behavior in downstream segment/subtitle routes. - `sd` (optional) - Segment duration. Defaults to `6.000` seconds. **Response**: - `Content-Type: application/x-mpegurl` - HLS master playlist text with video and subtitle tracks. **Errors**: - `400 Bad Request` if the token is invalid, the path is empty, or media duration is unavailable. - `500 Internal Server Error` if playlist generation fails. **Notes**: - Sidecar subtitle files are auto-discovered when no subtitle is already selected. --- #### GET /v1/api/player/m3u8/{token}[/{fake...}] Builds the HLS segment playlist for a signed media file. **Path**: - `token`: Short-lived playback token. **Response**: - `Content-Type: application/x-mpegurl` - VOD segment playlist referencing `/v1/api/player/segments/{token}/{segment}.ts`. **Errors**: - `304 Not Modified` if `If-Modified-Since` is present. - `400 Bad Request` if the token is invalid or expired. --- #### GET /v1/api/player/segments/{token}/{segment}[.{type}] Returns one MPEG-TS segment, either direct-played or transcoded with ffmpeg. **Path**: - `token`: Short-lived playback token. - `segment`: Zero-based segment number. - `type` (optional) - Accepted by the route but not used by the segment generator. **Query**: - `sd` (optional) - Override the duration of the final segment. **Response**: - `Content-Type: video/mpegts` - Streaming TS payload. **Headers**: - `X-Transcode-Time` - `X-Ffmpeg` and `X-Transcode-Config` when debug mode is enabled **Errors**: - `304 Not Modified` if `If-Modified-Since` is present. - `400 Bad Request` if the token is invalid, the path is empty, the path is not a file, or required hardware devices are missing. - `404 Not Found` if the media path or external subtitle path is missing. - `500 Internal Server Error` if ffprobe or ffmpeg fails. **Notes**: - Segment generation is serialized per playback token with a lock file. - External subtitles can be burned in. - Internal text subtitles can be extracted and burned in. --- #### GET /v1/api/player/subtitle/{token}/{type}.{source}{index}.m3u8 Builds a one-track HLS subtitle playlist. **Path**: - `token`: Short-lived playback token. - `type`: Subtitle type label used in the generated path, typically `webvtt`. - `source`: `x` for external subtitle files, `i` for internal subtitle streams. - `index`: Subtitle index. **Response**: - `Content-Type: application/x-mpegurl` - HLS subtitle playlist pointing at the subtitle conversion endpoint. **Errors**: - `304 Not Modified` if `If-Modified-Since` is present. - `400 Bad Request` if the token is invalid, there are no matching subtitles, or the selected subtitle cannot be found. --- #### GET /v1/api/player/subtitle/{token}/{source}{index}.{ext} Converts an external or internal subtitle track to WebVTT and streams it. **Path**: - `token`: Short-lived playback token. - `source`: `x` for external subtitle files, `i` for internal subtitle streams. - `index`: Single-digit subtitle index. - `ext`: Requested extension in the URL path. **Query**: - `reload` (optional) - Bypass the subtitle conversion cache. **Response**: - `Content-Type: text/vtt` - Converted subtitle body. - `X-Cache: hit|miss` **Errors**: - `304 Not Modified` if `If-Modified-Since` is present. - `400 Bad Request` if the token is invalid, the source is invalid, the subtitle codec is unsupported, or the target is not a subtitle stream. - `404 Not Found` if the media file or subtitle file does not exist. - `500 Internal Server Error` if conversion fails. **Notes**: - External formats such as `vtt`, `webvtt`, `srt`, and `ass` are supported. - Internal conversion currently supports text subtitle codecs listed in the player implementation. - The route includes `{ext}`, but the generated output is always WebVTT. --- ### State State routes operate on the main state database unless stated otherwise. #### GET /v1/api/state/media-health Returns the latest cached media health report summary and queue state. **Response**: ```json { "report": { "id": 1, "status": "completed", "generated_at": 1784520000, "completed_at": 1784520010, "version": 2, "state_count": 130025, "backend_count": 3, "summary": { "total": 130025, "actionable_count": 59076, "statuses": { "guid_conflict": 10, "healthy": 70949, "partial": 58000 }, "duration_seconds": 10, "memory_peak_bytes": 29360128, "memory_peak_mb": 28 }, "error": null }, "queued": false, "queued_event": null, "stale": false } ``` **Notes**: - `report` is `null` until the first successful audit completes. - `stale=true` means state data changed after the cached report was generated. --- #### GET /v1/api/state/media-health/items Returns paginated media health report items for the latest completed report. **Query**: - `page` (optional) - Defaults to `1`. - `perpage` (optional) - Defaults to `50`, maximum `500`. - `unhealthy` (optional) - Use `1` to exclude `healthy` items. - `status` (optional) - Filter by status: `guid_conflict`, `file_missing`, `duplicate_guid`, `duplicate_reference`, `metadata_disagreement`, `partial`, `weak_match`, `path_disagreement`, or `healthy`. - `type` (optional) - Filter by item type: `movie` or `episode`. **Response**: ```json { "report": { "id": 1, "status": "completed", "generated_at": 1784520000, "completed_at": 1784520010, "version": 2, "state_count": 130025, "backend_count": 3, "summary": { "total": 130025, "actionable_count": 59076, "statuses": { "healthy": 70949, "partial": 58000 } }, "error": null }, "items": [ { "id": 100, "report_id": 1, "state_id": 12345, "type": "movie", "title": "Movie Title", "year": 2024, "season": null, "episode": null, "status": "guid_conflict", "severity": 100, "confidence": 0, "backend_count": 3, "expected_backend_count": 3, "reasons": [ "Backends disagree on 'guid_tmdb' values '372767, 1545854'." ], "signals": { "backends": ["emby_main", "jf_main"], "missing_backends": [], "backend_items": { "emby_main": { "id": "abc", "type": "movie", "title": "Movie Title", "path": "/media/Movie Title.mkv", "webUrl": "https://emby.example.com/web/index.html#!/item?id=abc" } }, "paths": { "emby_main": "/media/Movie Title.mkv" }, "guids": { "guid_tmdb": "372767" }, "guid_conflicts": { "guid_tmdb": { "372767": ["emby_main"], "1545854": ["jf_main"] } } } } ], "paging": { "total": 59076, "perpage": 50, "current_page": 1, "first_page": 1, "next_page": 2, "prev_page": null, "last_page": 1182, "params": { "status": null, "unhealthy": "1", "type": null } } } ``` **Errors**: - `404 Not Found` if no completed media health audit exists. **Notes**: - Results are cached, not a live scan. - `signals.backend_items.*.webUrl` is best-effort and may be `null` if the backend cannot produce a web URL. - `metadata_disagreement` items store conflicting `type`, `year`, `season`, or `episode` values under `signals.metadata_conflicts`, grouped by value and reporting backend. - Local filesystem checks are disabled by default. Enable `WS_MEDIA_HEALTH_CHECK_FILES` from the Environment page then file signals may appear. --- #### POST /v1/api/state/media-health/run Queues a background task to regenerate the media health report. **Response**: ```json { "queued": true, "running": false, "event_id": "01J...", "message": "Media health audit was queued." } ``` **Notes**: - If an audit is already queued or running, the response is `202 Accepted` with `queued=false` and the existing event ID. - The task runs `state:media-health` via the task dispatcher. --- #### GET /v1/api/state/media-health/export/{format} Downloads a ZIP archive containing the latest completed report and all report items. **Path**: - `format` is `json`, `markdown`, or `csv`. **Notes**: - The export is a complete dump of all the data. - The archive contains files in the requested format. - Returns `404 Not Found` if no completed report exists. --- #### GET /v1/api/state/backend-report Returns the latest cached backend report for the selected identity and its queue state. **Response**: ```json { "report": { "id": 1, "status": "completed", "generated_at": 1784520000, "completed_at": 1784520002, "version": 1, "backend_count": 1, "identity": "main", "summary": { "total": 480, "watched": 285, "unwatched": 195, "in_progress": 8, "types": { "movie": { "total": 480, "watched": 285, "unwatched": 195, "in_progress": 8 }, "episode": { "total": 0, "watched": 0, "unwatched": 0, "in_progress": 0 } }, "backends": { "plex": { "total": 480, "watched": 285, "unwatched": 195, "in_progress": 8, "configured": true, "types": { "movie": { "total": 480, "watched": 285, "unwatched": 195, "in_progress": 8 }, "episode": { "total": 0, "watched": 0, "unwatched": 0, "in_progress": 0 } }, "libraries": [ { "id": "1", "total": 480, "watched": 285, "unwatched": 195, "in_progress": 8, "title": "Movies", "type": "movie" } ] } }, "origins": {} }, "error": null }, "queued": false, "queued_event": null } ``` **Notes**: - `report` is `null` until the first successful report completes. - Reports are stored and retained separately for each identity. - `in_progress` is included in `unwatched`. - Counts come from local state records. Library names are resolved from each configured backend. - Library `type` is `movie`, `show`, or `mixed`. - If library metadata cannot be resolved, `type` is inferred from its local records. --- #### POST /v1/api/state/backend-report/run Queues a background task to regenerate the backend report for the selected identity. **Response**: ```json { "queued": true, "running": false, "event_id": "01J...", "message": "Backend report was queued." } ``` **Notes**: - If a report is already queued or running, the response is `202 Accepted` with `queued=false` and the existing event ID. - Manually queued reports are tracked separately for each identity; the scheduled all-identity task appears queued for every identity. - The task runs `state:backend-report` through the task dispatcher. --- #### GET /v1/api/state/backend-report/export/{format} Downloads the latest completed backend report for the selected identity. **Path**: - `format` is `json`, `markdown`, or `csv`. **Notes**: - JSON contains the complete selected-identity summary. - CSV contains backend, library, and media-type counts. - Markdown contains headline and backend counts. --- #### GET /v1/api/state/user-statistics Returns the latest cached user statistics for the selected identity and its queue state. **Response**: ```json { "report": { "version": 1, "generated_at": 1784520000, "completed_at": 1784520002, "summary": { "movies": { "total": 0, "watched": 0, "in_progress": 0 }, "episodes": { "total": 2, "watched": 1, "in_progress": 1 }, "shows": { "total": 1, "started": 1, "in_progress": 1, "fully_watched": 0 } }, "genres": [ { "name": "drama", "movies": 0, "shows": 1, "total": 1 } ], "shows": [ { "title": "Example Series", "year": null, "total": 2, "watched": 1, "in_progress": 1, "status": "in_progress", "last_played_at": 1784510000, "history_id": 1 } ], "in_progress": { "movies": [], "episodes": [ { "title": "Example Series", "type": "episode", "year": 2020, "season": 1, "episode": 2, "progress": 123000, "history_id": 2 } ] }, "recent": [ { "title": "Example Series", "type": "episode", "year": 2020, "season": 1, "episode": 1, "played_at": 1784510000, "history_id": 1 } ] }, "queued": false, "queued_event": null, "stale": false } ``` **Notes**: - `report` is `null` until the first successful report completes. - `recent` contains up to 20 watched records with recorded play dates. --- #### POST /v1/api/state/user-statistics/run Queues a background task to regenerate user statistics for the selected identity. **Response**: ```json { "queued": true, "running": false, "event_id": "01J...", "message": "User statistics were queued." } ``` --- #### GET /v1/api/state/user-statistics/export/{format} Downloads the latest completed user statistics for the selected identity. **Path**: - `format` is `json` or `markdown`. **Notes**: - Exports ignore WebUI filters and pagination. - Returns `404 Not Found` if no completed report exists. --- ### System #### GET /v1/api/system/healthcheck Returns a simple liveness payload. **Access**: - Open. **Response**: ```json { "status": "ok", "message": "System is healthy" } ``` --- #### GET /v1/api/system/version Returns build and runtime version metadata. **Response**: ```json { "version": "dev-master", "build": "unknown", "sha": "unknown", "branch": "unknown", "container": true } ``` --- #### GET /v1/api/system/supported Returns the list of supported backend types. **Response**: ```json [ "plex", "jellyfin", "emby" ] ``` --- All `/v1/api/system/auth/*` routes are open. Routes that need credentials validate them themselves. #### GET /v1/api/system/auth/test Returns `200 OK` if the auth route group is reachable. **Response**: - `200 OK` with an empty body. --- #### GET /v1/api/system/auth/has_user Returns whether the system account exists and may include an auto-login token for trusted local clients. **Responses**: - `200 OK` with an empty body when no auto-login token is issued. - `204 No Content` when the system user/password is not configured. - JSON payload below when auto-login is allowed. ```json { "auto_login": true, "token": "..." } ``` **Notes**: - Returned user tokens expire after the configured auth token lifetime. Default: 2 days. **Errors**: - `500 Internal Server Error` if token encoding fails. --- #### GET /v1/api/system/auth/user Returns the decoded signed user token. **Auth**: - `Authorization: Token ` - `?ws_token=` **Response**: ```json { "username": "admin", "created_at": "2026-03-28T12:00:00+00:00", "expires_at": "2026-04-11T12:00:00+00:00", "refresh_required": false } ``` **Errors**: - `401 Unauthorized` for missing, invalid, or expired user tokens. - `500 Internal Server Error` if the system account is not configured. **Notes**: - `refresh_required` becomes `true` when the token is close enough to expiry that the client should call the refresh endpoint. --- #### POST /v1/api/system/auth/signup Creates the initial WatchState admin account. **Body**: ```json { "username": "admin", "password": "secret" } ``` **Response**: - `201 Created` with an empty body. **Errors**: - `400 Bad Request` if `username` or `password` is missing. - `403 Forbidden` if the system account is already configured. --- #### POST /v1/api/system/auth/login Exchanges username/password credentials for a signed user token. **Body**: ```json { "username": "admin", "password": "secret" } ``` **Response**: ```json { "token": "..." } ``` **Notes**: - Returned user tokens expire after the configured auth token lifetime. Default: 2 days. --- #### POST /v1/api/system/auth/refresh Re-issues the current signed user token when it is near expiry. **Auth**: - `Authorization: Token ` - `?ws_token=` **Response**: ```json { "token": "...", "username": "admin", "created_at": "2026-04-10T12:00:00+00:00", "expires_at": "2026-04-24T12:00:00+00:00", "refreshed": true } ``` **Errors**: - `401 Unauthorized` for missing, invalid, or expired user tokens. - `500 Internal Server Error` if the system account is not configured. **Errors**: - `400 Bad Request` if credentials are missing. - `401 Unauthorized` if the credentials are invalid. - `429 Too Many Requests` if rate limited after repeated failures. - `500 Internal Server Error` if the system account is not configured or token encoding fails. --- #### PUT /v1/api/system/auth/change_password Changes the configured system password. **Body**: ```json { "current_password": "old-secret", "new_password": "new-secret" } ``` **Response**: ```json { "info": { "code": 200, "message": "Password changed successfully." } } ``` **Errors**: - `400 Bad Request` if required fields are missing. - `401 Unauthorized` if the current password is invalid. - `500 Internal Server Error` if the stored password is missing or cannot be updated. --- #### DELETE /v1/api/system/auth/sessions Invalidates all signed user sessions by rotating the signing secret. **Response**: ```json { "info": { "code": 200, "message": "Sessions invalidated successfully." } } ``` **Errors**: - `500 Internal Server Error` if secret rotation fails. --- #### GET /v1/api/system/env Lists supported env keys and current values for non-protected entries. **Query**: - `set` (optional) - If true, only include keys present in the `.env` file. **Response**: ```json { "data": [ { "key": "WS_API_KEY", "description": "API key used for X-APIKEY authentication", "type": "string", "mask": true, "danger": true, "value": "***", "config_value": "...", "config": "api.key" } ], "file": "/config/.env" } ``` **Notes**: - Protected keys are omitted from external responses. --- #### GET /v1/api/system/env/{key} Reads one env key and its metadata. **Path**: - `key`: Env key name. **Response**: ```json { "key": "WS_API_KEY", "value": "secret", "description": "API key used for X-APIKEY authentication", "type": "string", "mask": true, "danger": true, "config_value": "secret", "config": "api.key" } ``` **Errors**: - `400 Bad Request` if the key is invalid. - `404 Not Found` if the key is unset or protected from external access. --- #### POST|DELETE /v1/api/system/env/{key} Sets or removes one env key. **Path**: - `key`: Env key name. **POST Body**: ```json { "value": "new-value" } ``` **Response**: - Returns the same metadata envelope as `GET /v1/api/system/env/{key}`. **Errors**: - `400 Bad Request` if the key is invalid, the value is missing, or validation fails. - `404 Not Found` if the key is protected from external access. **Notes**: - This route edits `/config/.env`. - `DELETE` removes the key from the `.env` file. - Bool, int, and float values are coerced according to the env spec. - Protected keys cannot be modified through external requests. --- #### GET /v1/api/system/guids Lists supported GUID types and validators. **Response**: ```json [ { "guid": "imdb", "type": "movie", "validator": { "pattern": "..." } } ] ``` --- #### GET /v1/api/system/guids/custom Reads the custom GUID configuration file. **Response**: ```json { "version": "0.0", "guids": [], "links": [] } ``` --- #### PUT /v1/api/system/guids/custom Adds a custom GUID definition. **Body**: ```json { "name": "letterboxd", "type": "plex", "description": "Letterboxd movie GUID", "validator": { "pattern": "/^[a-z0-9-]+$/", "example": "movie-name", "tests": { "valid": ["movie-name"], "invalid": ["movie name"] } } } ``` **Response**: ```json { "id": "uuid", "type": "plex", "name": "guid_letterboxd", "description": "Letterboxd movie GUID", "validator": { "pattern": "/^[a-z0-9-]+$/", "example": "movie-name", "tests": { "valid": ["movie-name"], "invalid": ["movie name"] } } } ``` **Errors**: - `400 Bad Request` if required fields are missing, the regex is invalid, or the tests do not match the supplied pattern rules. **Notes**: - If the name does not start with `guid_`, WatchState adds the prefix automatically. - `type` must be one of the configured supported backend/client names such as `plex`, `jellyfin`, or `emby`, not a media type like `movie`. --- #### DELETE /v1/api/system/guids/custom/{id} Deletes a custom GUID definition. **Path**: - `id`: Custom GUID UUID. **Response**: ```json { "id": "uuid", "name": "guid_letterboxd", "type": "plex" } ``` **Errors**: - `404 Not Found` if the GUID is not found. **Notes**: - Links that target the deleted GUID are removed automatically. --- #### GET /v1/api/system/guids/custom/{client} Lists custom GUID links for one backend client type. **Path**: - `client`: Backend type such as `plex`, `jellyfin`, or `emby`. **Response**: ```json [ { "id": "uuid", "type": "plex", "map": { "from": "GuidField", "to": "guid_letterboxd" } } ] ``` **Errors**: - `404 Not Found` if the client type is unsupported. --- #### PUT /v1/api/system/guids/custom/{client} Adds a custom GUID link for one backend client type. **Path**: - `client`: Backend type. **Body**: ```json { "type": "plex", "map": { "from": "GuidField", "to": "letterboxd" }, "options": { "legacy": true }, "replace": { "from": "old", "to": "new" } } ``` **Response**: ```json { "id": "uuid", "type": "plex", "map": { "from": "GuidField", "to": "guid_letterboxd" }, "options": { "legacy": true } } ``` **Errors**: - `400 Bad Request` if required fields are missing, the target GUID is unsupported, or the mapping already exists. **Notes**: - For Plex links, `options.legacy` is currently required and must be truthy to satisfy the current validation logic. --- #### DELETE /v1/api/system/guids/custom/{client}/{id} Deletes a custom GUID link for one backend client. **Path**: - `client`: Backend type. - `id`: Link UUID. **Response**: ```json { "id": "uuid", "type": "plex", "map": { "from": "GuidField", "to": "guid_letterboxd" } } ``` **Errors**: - `404 Not Found` if the link does not exist. --- #### GET /v1/api/system/guids/custom/{client}/{index} Returns the raw nested value stored at `{client}.{index}` in the custom GUID document. **Path**: - `client`: Backend type. - `index`: Numeric index. **Errors**: - `404 Not Found` if the client or requested index does not exist. **Notes**: - The current implementation looks up a nested path inside the custom GUID document, not an item from the `links` array shown by `GET /v1/api/system/guids/custom/{client}`. - In a typical custom GUID file this means the route often returns `404 Not Found` unless the document contains a matching top-level `{client}` object with a numeric child key. --- #### GET /v1/api/system/events Lists queued and historical events. **Query**: - `page` (optional) - Defaults to `1`. - `perpage` (optional) - Defaults to `10`. - `filter` (optional) - Partial match on the event name. **Response**: ```json { "paging": { "page": 1, "total": 25, "perpage": 10, "next": 2, "previous": null }, "items": [ { "id": "uuid", "event": "system:task", "status": 0, "status_name": "Pending", "event_data": {}, "options": {}, "attempts": 0, "created_at": "2026-03-28T12:00:00+00:00", "updated_at": "2026-03-28T12:00:00+00:00" } ], "statuses": [ { "id": 0, "name": "Pending" } ] } ``` --- #### GET /v1/api/system/events/stats Returns event counts grouped by status. **Query**: - `only` (optional) - Comma-separated list of status names. **Response**: ```json { "pending": 3, "running": 1, "completed": 10, "cancelled": 0, "failed": 0 } ``` **Errors**: - `400 Bad Request` if any status name is invalid. --- #### GET /v1/api/system/stats Returns the pending counts displayed in the global header. **Response**: ```json { "events": { "pending": 3 }, "transport": { "pending": 2 } } ``` --- #### GET /v1/api/system/transport/queue Lists items currently held by the transport queue. **Query**: - `page` (optional) - Page number. Defaults to 1. - `perpage` (optional) - Items per page. Defaults to 25 and is limited to 100. - `state` (optional) - `pending`, `processing`, or `failed`. - `filter` (optional) - Match an envelope ID or event name. **Response**: ```json { "items": [ { "id": "550e8400-e29b-41d4-a716-446655440000", "event": "on_webhook", "state": "pending", "created_at": "2026-03-28T12:00:00+00:00" } ], "paging": { "page": 1, "total": 1, "perpage": 25, "next": null, "previous": null }, "filter": { "state": "", "filter": "" }, "states": ["pending", "processing", "failed"] } ``` Queue item states are `pending`, `processing`, or `failed` when supported by the configured transport. **Errors**: - `400 Bad Request` if `state` is invalid. --- #### GET /v1/api/system/transport/queue/{id} Returns one transport envelope by its exact identifier without claiming it. **Response**: ```json { "id": "550e8400-e29b-41d4-a716-446655440000", "event": "on_webhook", "state": "pending", "created_at": "2026-03-28T12:00:00+00:00", "data": {}, "options": {} } ``` **Errors**: - `404 Not Found` if the envelope is not currently visible in the transport. --- #### POST /v1/api/system/events Queues a new event manually. **Body**: ```json { "event": "system:task", "event_data": { "name": "index" }, "DELAY_BY": 30 } ``` **Response**: ```json { "info": { "code": 202, "message": "Event 'system:task' was queued." }, "id": "uuid", "event": "system:task", "status": 0, "status_name": "Pending" } ``` **Errors**: - `400 Bad Request` if `event` is missing. --- #### GET /v1/api/system/events/{id} Returns an event. **Path**: - `id`: Event UUID. **Response**: ```json { "id": "uuid", "event": "system:task", "status": 0, "status_name": "Pending", "event_data": {} } ``` **Errors**: - `404 Not Found` if the event does not exist. --- #### PATCH /v1/api/system/events/{id} Updates an event's mutable fields. **Path**: - `id`: Event UUID. **Body**: ```json { "status": 3, "event": "system:task", "event_data": { "name": "index" }, "reset_logs": true } ``` **Response**: ```json { "info": { "code": 200, "message": "Updated" }, "id": "uuid", "status_name": "Completed" } ``` **Errors**: - `400 Bad Request` if the event is running, `status` is not numeric, or the status value is invalid. - `404 Not Found` if the event does not exist. --- #### DELETE /v1/api/system/events/{id} Deletes one event. **Path**: - `id`: Event UUID. **Response**: ```json { "id": "uuid", "event": "system:task", "status_name": "Cancelled" } ``` **Errors**: - `400 Bad Request` if the event is currently running. - `404 Not Found` if the event does not exist. --- #### DELETE /v1/api/system/events Deletes all non-pending events. **Response**: - `200 OK` with an empty body. **Notes**: - Pending events are preserved. --- #### POST /v1/api/system/command Queues a one-time command for execution. **Headers**: - Standard API auth is still required. - `X-Signature: sha256=` - `X-Sign-With: token|api` `X-Sign-With` selects which presented credential is used to verify the HMAC over the raw JSON body: - `token`: verify against the authenticated user token - `api`: verify against the presented API key If `X-Sign-With` is omitted, WatchState defaults to `api`. **Body**: ```json { "command": "db:index", "cwd": "/home/coders/apps/watchstate", "timeout": 120, "force_color": true } ``` **Response**: ```json { "token": "sha256-token", "tracking": "/v1/api/system/command/sha256-token", "expires": "2026-03-28T12:05:00+00:00" } ``` **Errors**: - `400 Bad Request` if the body is empty or `command` is missing/invalid. - `400 Bad Request` if the request signature is missing, malformed, or uses an unsupported verifier/algorithm. - `403 Forbidden` if the signature does not match the selected credential. - `503 Service Unavailable` if command execution is unavailable. --- #### GET /v1/api/system/command Lists recent command sessions that are still available for attach or replay. **Response**: ```json { "items": [ { "token": "sha256-token", "command": "db:index", "status": "completed", "cwd": "/home/coders/apps/watchstate", "created_at": "2026-03-28T12:00:00+00:00", "updated_at": "2026-03-28T12:02:00+00:00", "started_at": "2026-03-28T12:00:01+00:00", "finished_at": "2026-03-28T12:02:00+00:00", "expires_at": "2026-03-28T12:05:00+00:00", "available_until": "2026-03-29T12:02:00+00:00", "exit_code": 0, "outcome": "success", "failure_reason": null, "last_sequence": 42, "connections": 0 } ] } ``` **Notes**: - Queued and running sessions use `expires_at` as their availability window. - Completed sessions remain replayable for about 24 hours after `finished_at`. - `outcome` identifies how the command ended. --- #### GET /v1/api/system/command/{token} Attaches to an available command session and streams or replays its output. **Path**: - `token`: Command token returned by `POST /v1/api/system/command`. **Response**: - `Content-Type: text/event-stream` **Event Names**: - `cmd` - `cwd` - `data` - `exit_code` - `close` **Example `data` Event Payload**: ```json { "data": "Console output line\n", "type": "out" } ``` **Errors**: - `404 Not Found` if the token is invalid, the queued request expired, or the completed session exceeded its replay window. **Notes**: - Sessions are resumable while the command is still active. - Use `Last-Event-ID` or `?since=` to resume from a known event sequence. - Completed sessions remain replayable for about 24 hours after `finished_at`. --- #### DELETE /v1/api/system/command/{token} Requests cancellation for a queued or running command. **Path**: - `token`: Command token returned by `POST /v1/api/system/command`. **Response**: ```json { "message": "Command cancellation requested." } ``` **Errors**: - `404 Not Found` if the token is invalid/expired. **Notes**: - Queued cancellations are retained as completed sessions with the `cancelled` outcome. - Running-command cancellation is asynchronous. - Completed sessions return `202 Accepted` with `Command has already completed.` --- #### GET /v1/api/system/scheduler Returns worker status. **Response**: ```json { "pid": "1234", "status": true, "restartable": true, "message": "Worker is running." } ``` --- #### POST /v1/api/system/scheduler/restart Starts the worker when it is not running. **Response**: ```json { "status": true, "restartable": true, "message": "Worker restart requested." } ``` **Errors**: - `400 Bad Request` if WatchState is not running in a container. **Notes**: - Admin operation. Starts the background worker inside the container. --- #### GET /v1/api/system/report Returns a structured diagnostic report. **Response**: ```json { "generated_at": "2024-01-01T12:00:00+00:00", "system": { "version": "v1.0.0", "php_version": "8.4.0", "sapi": "cli", "timezone": "UTC", "data_path": "/config", "temp_path": "/tmp", "database_migrated": true, "env_file_exists": true, "scheduler_running": true, "scheduler_message": "OK", "in_container": true }, "users": ["main"], "backends": [ { "name": "plex_server", "user": "main", "type": "plex", "version": "1.40.1", "https": true, "has_uuid": true, "has_user": true, "export": { "enabled": true, "last_sync": 1704110400, "playlist_last_sync": null }, "import": { "enabled": true, "metadata_refresh": true, "last_sync": 1704110400, "playlist_last_sync": null }, "options": {} } ], "suppression": { "file_exists": false, "rules": null, "error": null }, "tasks": [ { "name": "import", "enabled": true, "args": "-v", "timer": "5 * * * *", "next_run": "2024-01-01T13:00:00+00:00", "error": null } ], "logs": [ { "type": "app", "entries": [ { "datetime": "2024-01-01T12:00:00+00:00", "level": "INFO", "logger": "main", "message": "Application started" } ] } ] } ``` --- #### GET /v1/api/system/report/ini Returns `ini_get_all()` for development builds. **Response**: ```json { "content": { "memory_limit": { "local_value": "512M", "global_value": "512M" } } } ``` **Errors**: - `403 Forbidden` outside development builds. --- #### POST /v1/api/system/url/check Performs an outbound HTTP request for debugging connectivity, headers, and upstream responses. **Body**: ```json { "url": "https://example.com", "method": "GET", "headers": [ { "key": "Authorization", "value": "Bearer ..." }, { "key": "ws-timeout", "value": "15" } ] } ``` **Response**: ```json { "request": { "url": "https://example.com", "method": "GET", "headers": { "Authorization": "Bearer ..." } }, "response": { "status": 200, "headers": { "content-type": "text/html" }, "body": "..." } } ``` **Errors**: - `400 Bad Request` if `url` is missing, invalid, or if the HTTP method is invalid. **Notes**: - Transport failures still return HTTP `200`, but the embedded `response.status` becomes `500` and the embedded headers include `WS-Exception` and `WS-Error`. - This is a high-risk admin endpoint because it can probe arbitrary URLs. --- #### POST /v1/api/system/yaml[/{filename}] Converts the parsed request body to YAML. **Query**: - `inline` (optional) - Inline nesting depth. Defaults to `4`. - `indent` (optional) - Indent width. Defaults to `2`. **Body**: - Any parsed JSON payload. **Response**: - `Content-Type: text/yaml` - YAML body rendered from the request payload. **Errors**: - `400 Bad Request` if YAML generation fails. **Notes**: - When `filename` is supplied, the response is returned as an attachment. --- #### POST /v1/api/system/sign/{id} Creates a short-lived playback token for a filesystem path associated with a history item. **Path**: - `id`: Numeric local history record ID. **Body**: ```json { "path": "/media/movies/Movie Title (2024).mkv", "time": "PT24H", "config": { "audio": 1, "subtitle": 2, "debug": false } } ``` **Response**: ```json { "token": "play-abcdef123456", "expires": "2026-03-29T12:00:00+00:00" } ``` **Errors**: - `400 Bad Request` if `path` is empty or the reference entity does not exist. - `404 Not Found` if the filesystem path does not exist. --- #### GET /v1/api/system/static/{file} Serves exported UI assets and allowlisted documentation files. **Access**: - Open. **Path**: - `file`: Path relative to the static file root. **Response**: - File stream with `Content-Type`, `Content-Length`, and `Last-Modified`. **Errors**: - `400 Bad Request` if the path is invalid. - `404 Not Found` if the file does not exist. --- #### GET /v1/api/system/images/{type} Returns a random poster or background image from the current history database. **Path**: - `type`: `poster` or `background` **Query**: - `force` (optional) - Ignore the cached random selection and choose a new item. **Response**: - Binary image stream. **Errors**: - `204 No Content` if no user context, no history rows, or no usable image can be found. --- #### GET /v1/api/system/backup Lists available backup files. **Response**: ```json [ { "filename": "watchstate.20260328.json.zip", "type": "automatic", "size": 12345, "date": "2026-03-28T12:00:00+00:00" } ] ``` --- #### GET|DELETE /v1/api/system/backup/{filename} Downloads or deletes one backup file. **Path**: - `filename`: Backup file name. **GET Response**: - Raw file stream with detected `Content-Type`. **DELETE Response**: - `200 OK` with an empty body. **Errors**: - `400 Bad Request` if the resolved file path escapes the backup directory. - `404 Not Found` if the file does not exist. **Notes**: - `DELETE` permanently removes the backup file. --- #### GET /v1/api/system/processes Returns the current OS process list. **Response**: ```json { "processes": [ { "user": "root", "pid": "123", "cpu": "0.0", "mem": "0.1", "command": "php-fpm" } ] } ``` **Errors**: - `500 Internal Server Error` if `ps aux` fails. --- #### DELETE /v1/api/system/processes/{id} Terminates one process by PID. **Path**: - `id`: Numeric PID. **Response**: - `200 OK` with an empty body. **Errors**: - `400 Bad Request` if the PID is invalid. - `404 Not Found` if the process does not exist. - `500 Internal Server Error` if `SIGTERM` or `SIGKILL` fails. **Notes**: - Admin operation. WatchState sends `SIGTERM`, waits up to 5 seconds, then escalates to `SIGKILL` if needed. --- #### DELETE /v1/api/system/cache Flushes the Redis cache database. **Response**: ```json { "info": { "code": 200, "message": "Cache purged successfully." } } ``` **Errors**: - `500 Internal Server Error` if Redis flush fails. **Notes**: - Flushes the entire Redis database used by WatchState. --- #### DELETE /v1/api/system/reset Resets all user databases, clears sync timestamps, and flushes Redis. **Response**: ```json { "message": "System reset is complete." } ``` **Notes**: - One of the most destructive routes in the API. Resets every user database, clears sync timestamps, and flushes Redis. --- #### POST /v1/api/system/reset/opcache Resets PHP OPCache. **Response**: ```json { "message": "OPCache reset is complete." } ``` **Notes**: - Affects PHP opcode cache for the running environment. --- #### GET /v1/api/system/suppressor Lists log suppressor rules. **Response**: ```json { "items": [ { "id": "S1234567890", "type": "contains", "rule": "healthcheck", "example": "GET /healthcheck" } ], "types": ["regex", "contains"] } ``` --- #### POST /v1/api/system/suppressor Adds a log suppressor rule. **Body**: ```json { "rule": "healthcheck", "type": "contains", "example": "GET /healthcheck" } ``` **Response**: ```json { "id": "S1234567890", "type": "contains", "rule": "healthcheck", "example": "GET /healthcheck" } ``` **Errors**: - `400 Bad Request` if required fields are missing, the type is invalid, the regex is invalid, the example does not match, or another rule already suppresses the example. --- #### GET /v1/api/system/suppressor/{id} Returns a suppressor rule. **Path**: - `id`: 11 character suppressor rule ID. **Response**: ```json { "id": "S1234567890", "type": "contains", "rule": "healthcheck", "example": "GET /healthcheck" } ``` **Errors**: - `400 Bad Request` if the ID is invalid. - `404 Not Found` if the rule does not exist. --- #### PUT /v1/api/system/suppressor/{id} Replaces one suppressor rule. **Path**: - `id`: 11 character suppressor rule ID. **Body**: ```json { "rule": "new-regex", "type": "regex", "example": "Some log line" } ``` **Response**: - Same payload shape as create/view. **Errors**: - Same validation errors as `POST /v1/api/system/suppressor`. --- #### DELETE /v1/api/system/suppressor/{id} Deletes one suppressor rule. **Path**: - `id`: 11 character suppressor rule ID. **Response**: ```json { "id": "S1234567890", "type": "contains", "rule": "healthcheck", "example": "GET /healthcheck" } ``` **Errors**: - `400 Bad Request` if the ID is invalid. - `404 Not Found` if the rule does not exist. --- ### Tasks #### GET /v1/api/tasks Lists scheduled tasks and shows which ones are queued. **Response**: ```json { "tasks": [ { "name": "index", "description": "Rebuild indexes", "enabled": true, "timer": "*/30 * * * *", "next_run": "2026-03-28T12:30:00+00:00", "prev_run": "2026-03-28T12:00:00+00:00", "command": "db:index", "args": [], "hide": false, "allow_disable": true, "queued": false } ], "queued": [] } ``` **Notes**: - Hidden tasks are omitted from the list. --- #### GET /v1/api/tasks/{id} Returns a task definition. **Path**: - `id`: Task name. **Response**: ```json { "name": "index", "description": "Rebuild indexes", "enabled": true, "timer": "*/30 * * * *", "next_run": "2026-03-28T12:30:00+00:00", "prev_run": "2026-03-28T12:00:00+00:00", "command": "db:index", "args": [], "hide": false, "allow_disable": true, "queued": false } ``` **Errors**: - `404 Not Found` if the task is unknown. --- #### GET|POST|DELETE /v1/api/tasks/{id}/queue Gets queue state, queues a run, or cancels a queued run. **Path**: - `id`: Task name. **GET Response**: ```json { "task": "index", "is_queued": false } ``` **POST Response**: ```json { "id": "uuid", "event": "system:task", "status": 0, "...": "queued event payload" } ``` **DELETE Response**: - `200 OK` with an empty body. **Errors**: - `404 Not Found` if the task does not exist or, for `DELETE`, if it is not queued. - `409 Conflict` if the task is already queued. - `400 Bad Request` if you try to remove a running task. **Notes**: - `POST` returns `202 Accepted`. --- ### Prune #### GET /v1/api/prune Lists all prune handlers. **Response**: ```json { "pruners": [ { "name": "backend_metadata", "display_name": "Backend Metadata", "description": "Remove metadata for deleted backends.", "cron": "35 */12 * * *", "enabled": true, "next_run": "2026-03-28T12:35:00+00:00" }, ... ] } ``` **Notes**: - Pruners that do not have a cron schedule or are disabled return `null` for `cron` and `next_run`. --- ### Identities #### GET /v1/api/identities Lists configured WatchState identities and each identity's backend names. **Response**: ```json { "identities": [ { "identity": "main", "backends": ["plex_main", "jellyfin_main"] } ] } ``` --- #### POST /v1/api/identities Creates a new WatchState identity configuration set. **Body**: ```json { "identity": "family" } ``` **Response**: - `201 Created` with an empty body. **Errors**: - `400 Bad Request` if `identity` is missing or invalid. - `409 Conflict` if the identity already exists. - `500 Internal Server Error` if creation fails. **Notes**: - Identity names are normalized to lowercase. --- #### DELETE /v1/api/identities/{identity} Deletes one WatchState identity configuration set. **Path**: - `identity`: Identity name. **Response**: - `200 OK` with an empty body. **Errors**: - `400 Bad Request` if the identity name is missing or invalid. - `403 Forbidden` if the identity is `main`. - `404 Not Found` if the identity does not exist. - `500 Internal Server Error` if deletion fails. --- #### GET /v1/api/identities/{identity} Returns the full backend config object for one identity. **Path**: - `identity`: Identity name. **Response**: ```json { "plex_main": { "type": "plex", "url": "https://plex.example.com", "token": "..." } } ``` **Errors**: - `400 Bad Request` if the identity name is missing or invalid. - `404 Not Found` if the identity does not exist. --- #### PUT /v1/api/identities/{identity} Replaces the full backend config object for one identity. **Path**: - `identity`: Identity name. **Body**: - JSON object or YAML document describing the entire backend config file. **JSON Example**: ```json { "plex_main": { "type": "plex", "url": "https://plex.example.com", "token": "..." } } ``` **Response**: ```json { "plex_main": { "type": "plex", "url": "https://plex.example.com", "token": "..." } } ``` **Errors**: - `400 Bad Request` if the identity name is invalid, the body cannot be parsed, the body is not an object, or validation fails. - `404 Not Found` if the identity does not exist. **Notes**: - Accepts JSON and YAML request bodies. - Requests with `application/json` are parsed as JSON; other request bodies are parsed as YAML. - Validation errors include an `errors` array in the response body. --- #### GET /v1/api/identities/provision Returns the current identity provisioning preview built from live backend member discovery and the saved mapper file. **Query**: - `force` (optional) - Bypass the 5 minute cache and rebuild the preview. **Response**: ```json { "has_identities": true, "has_mapping": true, "backends": ["plex_main", "jellyfin_main"], "matched": [ { "identity": "alice", "members": [ { "id": "123", "username": "alice", "backend": "plex_main", "real_name": "Alice", "type": "plex", "protected": false, "options": {} } ] } ], "unmatched": [ { "id": "789", "username": "bob", "backend": "plex_main", "real_name": "Bob", "type": "plex", "protected": true, "options": {} } ], "expires": "2026-03-28T12:05:00+00:00" } ``` **Notes**: - `has_identities` means generated identity YAML files already exist under `users/*/*.yaml`. - `has_mapping` means a non-empty mapper file was loaded. - Matching can still happen with `has_mapping=false` through direct normalized username matching. - `username` is the normalized internal name. `real_name` is the original backend-reported name. --- #### PUT /v1/api/identities/provision/mapping Creates or replaces the saved cross-backend identity mapping file. **Body**: ```json { "version": "1.6", "identities": [ { "identity": "alice", "members": [ { "backend": "plex_main", "username": "alice", "options": {} }, { "backend": "jellyfin_main", "username": "alice", "options": {} } ] } ] } ``` **Response**: ```json { "info": { "code": 200, "message": "Identity mapping successfully updated." }, "version": "1.6", "identities": [ { "identity": "alice", "members": [ { "backend": "plex_main", "username": "alice", "options": {} } ] } ] } ``` **Errors**: - `400 Bad Request` if `identities` is missing, empty, not an array, or if `version` is lower than `1.5`. --- #### POST /v1/api/identities/provision Creates, updates, or recreates identities directly through the API. **Body**: ```json { "mode": "update", "dry_run": false, "generate_backup": true, "regenerate_tokens": false, "allow_single_backend_identities": false, "save_mapping": true, "mapping": { "version": "1.6", "identities": [ { "identity": "alice", "members": [ { "backend": "plex_main", "username": "alice", "options": {} }, { "backend": "jellyfin_main", "username": "alice", "options": {} } ] } ] } } ``` **Response**: ```json { "info": { "code": 200, "message": "Identities updated successfully." }, "mode": "update", "dry_run": false, "save_mapping": true, "allow_single_backend_identities": false, "count": 1, "identities": [ { "identity": "alice", "backends": ["plex_main", "jellyfin_main"], "members": [ { "backend": "plex_main", "username": "alice" }, { "backend": "jellyfin_main", "username": "alice" } ] } ] } ``` **Errors**: - `400 Bad Request` if `mode` is invalid or provisioning cannot produce identities. - `409 Conflict` if `mode=create` is requested while local identities already exist. - `500 Internal Server Error` if provisioning fails unexpectedly. **Notes**: - `mode` accepts `create`, `update`, or `recreate`. - `save_mapping=true` persists the provided mapping before provisioning. - `allow_single_backend_identities=true` requires exactly one configured backend and allows one-member identity groups. --- #### POST /v1/api/identities/provision/sync-backends Safely syncs already-linked identity backends from the current main backend configuration. This route does not create, delete, or rematch identities. It only updates existing linked backend configs. **Body**: ```json { "dry_run": false } ``` **Response**: ```json { "info": { "code": 200, "message": "Synced 4 identity backend(s) successfully." }, "dry_run": false, "updated_count": 4, "skipped_count": 2, "failed_count": 0, "updated": [ { "identity": "alice", "backend": "plex_alice", "source_backend": "plex_main" } ], "skipped": [ { "identity": "manual_profile", "backend": "custom_backend", "reason": "Backend is not linked to a source backend." } ], "failed": [] } ``` **Notes**: - This is the safe maintenance path for propagating changes like backend URL, shared tokens, UUID, import/export settings, and shared backend options. - Per-identity values such as backend user IDs, Plex child tokens, Plex user UUID/name, and protected user PINs are preserved. - `dry_run=true` reports what would change without writing any identity config files. --- ### Webhook #### POST|PUT /v1/api/webhook Receives backend webhook payloads, matches them to configured users/backends, and queues import processing. **Access**: - Open when `WS_SECURE_API_ENDPOINTS=false`. - Otherwise standard API auth. **Input**: - Backend-specific headers and payload. - The body shape depends on the backend sending the webhook. **Response**: - `200 OK` when the webhook was parsed and queued successfully. - `304 Not Modified` when the payload is intentionally ignored. - `406 Not Acceptable` when import is disabled for the target backend. **Errors**: - `400 Bad Request` if no backend parser can recognize the payload. - `400 Bad Request` if the payload lacks the backend unique ID. - `400 Bad Request` if a non-generic payload lacks the backend user ID. - `400 Bad Request` if the payload does not map to any configured user/backend pair. **Notes**: - The endpoint fans out across all matching user/backend pairs for generic webhook payloads. - Unsupported or unusable items are ignored with `304 Not Modified`, for example: - items without supported GUIDs - episode events without season/episode numbers - generic requests that the backend parser cannot fully resolve --- ## Error Responses Most endpoints return standard error codes (`400`, `401`, `403`, `404`, `409`, `500`, etc.) and a JSON envelope on failure. For example: ```json { "error": { "code": 400, "message": "Description of the problem" } } ``` Informational success responses use the same structure under `info`: ```json { "info": { "code": 200, "message": "Human readable message" } } ``` Some endpoints return a bare array, `204 No Content`, SSE, HLS text, or binary file content instead of a JSON envelope. ---