API.md
Rama master · réplica de lectura del repositorio original.
# 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: <api-key>
```
2. API key in the query string:
```
?apikey=<api-key>
```
3. Signed user token in the `Authorization` header for most authenticated routes:
```
Authorization: Bearer <token>
```
or
```
Authorization: Token <token>
```
4. Signed user token in the query string:
```
?ws_token=<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: <name>` or `?user=<name>`.
- 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=<scoped>`.
---
#### 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 <token>`
- `?ws_token=<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 <token>`
- `?ws_token=<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=<hmac>`
- `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.
---