From 8dc452734443fbda765c26f20882e4e83cf30869 Mon Sep 17 00:00:00 2001 From: enoch85 Date: Sun, 30 Aug 2026 01:56:04 +0000 Subject: [PATCH 1/2] docs: document every Maintainerr API endpoint Replace the partial API page with complete reference documentation for all 219 endpoints, split into twelve per-area pages under docs/api/. Also regenerate the bundled OpenAPI YAML from the current server: it carried 120 paths against 187 live, missing 72 and listing 5 that no longer exist. --- docs/API.md | 261 +- docs/api/app-and-health.md | 175 + docs/api/collections.md | 825 ++++ docs/api/logs.md | 179 + docs/api/media-server.md | 644 +++ docs/api/metadata-and-storage.md | 284 ++ docs/api/notifications.md | 296 ++ docs/api/overlays.md | 711 +++ docs/api/rules.md | 715 ++++ docs/api/seerr.md | 175 + docs/api/servarr.md | 150 + docs/api/settings.md | 1236 ++++++ docs/api/streamystats.md | 109 + sidebars.js | 16 +- .../openapi-spec/maintainerr_api_specs.yaml | 3810 +++++++++++------ 15 files changed, 8117 insertions(+), 1469 deletions(-) create mode 100644 docs/api/app-and-health.md create mode 100644 docs/api/collections.md create mode 100644 docs/api/logs.md create mode 100644 docs/api/media-server.md create mode 100644 docs/api/metadata-and-storage.md create mode 100644 docs/api/notifications.md create mode 100644 docs/api/overlays.md create mode 100644 docs/api/rules.md create mode 100644 docs/api/seerr.md create mode 100644 docs/api/servarr.md create mode 100644 docs/api/settings.md create mode 100644 docs/api/streamystats.md diff --git a/docs/API.md b/docs/API.md index 87b95c2dc..8abb15ecf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -13,193 +13,106 @@ hide: ::: -## API endpoints +Maintainerr exposes 219 HTTP endpoints. Every one of them is documented in the pages below, grouped by area. -:::info -The Docusaurus site does not yet embed the generated Swagger reference. Use the live Swagger UI in your Maintainerr instance at `http:///api/swagger` for the current API surface, including modules such as overlays and storage metrics. -::: +## Endpoints by area + +| Page | Endpoints | Covers | +| ------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------- | +| [Settings](./api/settings.md) | 74 | Every integration's connection settings, connection tests, the media server switch | +| [Rules](./api/rules.md) | 29 | Rule groups, execution, exclusions, community rules, YAML import and export | +| [Overlays](./api/overlays.md) | 28 | Overlay settings, processing runs, templates, fonts and images | +| [Collections](./api/collections.md) | 25 | Collections, membership, bulk media actions, handling, posters, logs | +| [Media server](./api/media-server.md) | 23 | Libraries, items, search, watch state, users, media server collections | +| [Notifications](./api/notifications.md) | 8 | Agents, configurations, rule group links, test sends | +| [App and health](./api/app-and-health.md) | 7 | App status, time zone, releases, health probes, task status | +| [Logs](./api/logs.md) | 6 | Log stream, log files, log level settings | +| [Seerr](./api/seerr.md) | 6 | Seerr lookups, requester names, request and media deletion | +| [Metadata, storage and events](./api/metadata-and-storage.md) | 6 | Metadata provider lookups, storage metrics, the events stream | +| [Servarr](./api/servarr.md) | 5 | Radarr, Sonarr and Sportarr disk space and quality profiles | +| [Streamystats](./api/streamystats.md) | 2 | Streamystats server info and per-item watch statistics | + +## API conventions + +These hold across the whole API. They are worth reading once before using any endpoint. + +### No authentication + +There is no authentication anywhere. The only access check in Maintainerr confirms that a media server is configured, not who is calling. + +`GET /api/settings/api/generate` mints an API key, but **nothing server-side ever validates an inbound `X-Api-Key`**. That key exists only so Maintainerr's own internal client can call its own API. Generating one does not protect anything. + +Anyone who can reach the port can call every endpoint, including the ones that delete media. See [Security & Authentication](./Security.md) for how to put access control in front of it. -The repository also carries a bundled OpenAPI YAML at `static/openapi-spec/maintainerr_api_specs.yaml`, but the live Swagger UI should still be treated as the source of truth for the running instance. +### No rate limiting -## Notable endpoints +No request throttling is configured. There is nothing to stop a caller making unlimited requests. -These sections cover notable user-facing API groups. +### Base path -### Health +When `BASE_PATH` is set it prefixes every path. All paths in these docs are shown in their unprefixed form, so add your prefix to each. -Maintainerr exposes lightweight health endpoints under `/api/health` (prefixed with `BASE_PATH` when set) for orchestration probes and uptime monitoring. +### CORS -| Endpoint | Purpose | -| ----------------------- | ------------------------------------------------------------------------------------------ | -| `GET /api/health/live` | Liveness probe; returns `200` while the process is running and does not touch the database | -| `GET /api/health/ready` | Readiness probe; runs a database `SELECT 1` check and returns `200` or `503` | -| `GET /api/health` | Convenience alias that mirrors `/api/health/ready` | +In production no CORS middleware is registered at all unless `CORS_ALLOWED_ORIGINS` is set, so no `Access-Control-Allow-Origin` header can be sent and browser calls from another origin will fail. In development the origin that asks is reflected back. -`GET /api/health/live` returns a lightweight envelope such as: +### Request validation + +Bodies are validated per endpoint. Where a schema exists, a failure returns: ```json -{ - "status": "ok", - "uptimeSeconds": 1234, - "timestamp": "2026-06-05T12:00:00.000Z" -} +{ "statusCode": 400, "message": "Validation failed", "errors": [] } ``` -`GET /api/health/ready` and `GET /api/health` include database status: +`errors` holds the individual validation problems. + +Validation is not universal. Of the 70 endpoints that take a body, **17 have no schema at all**, so the body reaches the service unchecked. Most numeric path parameters are checked and reject a non-numeric value with a `400` before the handler runs, but not all: `DELETE /api/notifications/configuration/{id}` declares a numeric id without that check. + +### Success and failure in the same status code + +Many endpoints, settings especially, report failure as HTTP `200` with a body like this: ```json -// 200 -{ "status": "ok", "uptimeSeconds": 1234, "database": "ok", "timestamp": "..." } -// 503 -{ "status": "degraded", "uptimeSeconds": 1234, "database": "unreachable", "timestamp": "..." } +{ "status": "NOK", "code": 0, "message": "why it failed" } ``` -- Only the database gates readiness. External integrations such as Plex/Jellyfin, the `*arr` stack, Seerr, Tautulli, and Streamystats are intentionally excluded so transient upstream outages do not take Maintainerr out of rotation. -- The bundled Docker image ships a `HEALTHCHECK` that calls `/api/health/ready` and honours both `BASE_PATH` and `UI_PORT`. -- For Kubernetes, use `/api/health/live` as the `livenessProbe` and `/api/health/ready` as the `readinessProbe`. If `BASE_PATH` is set, prefix both probe paths accordingly. - -### Collections - -| Endpoint | Purpose | -| ------------------------------------ | ------------------------------------------------------------------------------------------------- | -| `GET /api/collections/overlay-data` | Returns collections with full media membership for overlay consumers, including the Calendar page | -| `POST /api/collections/media/handle` | Run the configured collection action immediately for one item from the collection detail modal | -| `GET /api/collections/:id/poster` | Return the stored custom collection poster as `image/jpeg`, or `404` when none exists | -| `POST /api/collections/:id/poster` | Upload a custom collection poster with multipart field `poster`; returns `{ pushed, attempted }` | -| `DELETE /api/collections/:id/poster` | Clear the stored poster and return `{ cleared, refreshRequested }` | - -The lower-level `POST /api/media-server/collection` request body matches `CreateCollectionParams`: `libraryId`, `title`, and `type` are required, with optional `summary`, `sortTitle`, and `initialItemId`. `initialItemId` is a single media-server item id used when a collection must be created with one initial member; remaining items are still added afterwards through the normal collection-sync path. - -### Bulk media actions - -These back the `Add / Remove Media` modal described in [Collections](./Collections.md#add-remove-media-modal). - -| Endpoint | Purpose | -| ---------------------------------- | ---------------------------------------------------------------------------------- | -| `POST /api/collections/media/bulk` | Add a media selection to one collection, or remove it from one or from all of them | -| `POST /api/rules/exclusions/bulk` | Exclude a media selection, or drop its exclusions, globally or for one rule group | - -Both endpoints take the same fields: - -| Field | Description | -| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mediaIds` | 1 to 250 media-server ids | -| `action` | `0` adds, `1` removes | -| `collectionId` | Limits the call to one collection. Leave it out to mean every collection, which `/collections/media/bulk` allows only for a removal | -| `mediaType` | `movie`, `show`, `season`, or `episode`. Required by `/collections/media/bulk`, so the server can work out the seasons and episodes without looking up each item | -| `context` | Optional `{ id, type }` that narrows a one-item selection to a single season or episode. Sending it with more than one id is an error | - -The 250 limits one request, not how much a user can select. The web UI sends 25 ids per request and splits a bigger selection across several calls, so only direct API callers reach it. - -If some items fail, the rest still go through. Both endpoints answer `{ results: [{ mediaId, code, message? }] }`, where `code` is `1` for success and `0` for failure, with `message` explaining a failure. A request is rejected outright with `400` only when it is empty, holds more than 250 ids, or asks to add without naming a collection. - -Adding an exclusion takes the collection and rule execution lock, on both `POST /api/rules/exclusions/bulk` and `POST /api/rules/exclusion`, so it cannot land while a run is acting on the same item. Both wait up to 30 seconds for a running job and answer `409` if it is still going. Removing an exclusion takes no lock. - -### Metadata - -| Endpoint | Purpose | -| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET /api/metadata/backdrop/:type` | Resolve a backdrop image from the configured metadata providers. `:type` is `movie` or `show` | -| `GET /api/metadata/overview/:type` | Resolve an overview from the configured metadata providers. `:type` is `movie` or `show`; pass a season's or episode's `itemId` to get that item's overview | -| `GET /api/metadata/image/:type` | Resolve a poster image from the configured metadata providers. `:type` is `movie` or `show` | -| `GET /api/settings/tmdb` | Read the saved TMDB API key state | -| `POST /api/settings/tmdb` | Save a TMDB API key | -| `DELETE /api/settings/tmdb` | Remove the saved TMDB API key | -| `GET /api/settings/tvdb` | Read the saved TVDB API key state | -| `POST /api/settings/tvdb` | Save a TVDB API key | -| `DELETE /api/settings/tvdb` | Remove the saved TVDB API key | -| `GET /api/settings/metadata-provider` | Read which metadata provider is currently primary | -| `POST /api/settings/metadata-provider` | Change the primary metadata provider | -| `POST /api/settings/metadata/refresh/:provider` | Clear cached metadata for TMDB or TVDB and queue a media-server refresh pass | - -### Media server settings - -| Endpoint | Purpose | -| ------------------------------- | ----------------------------------------------------------------------------------------------- | -| `GET /api/settings/emby` | Read the saved Emby URL, API key, and selected admin user | -| `POST /api/settings/emby/test` | Test an Emby URL and API key, and return available admin users | -| `POST /api/settings/emby` | Save Emby connection settings | -| `DELETE /api/settings/emby` | Remove the saved Emby connection settings | -| `POST /api/settings/emby/login` | Authenticate with an Emby admin username/password and return an API key plus admin-user choices | - -### Download Client - -| Endpoint | Purpose | -| ----------------------------------------- | ------------------------------------------------------------------------------- | -| `GET /api/settings/download-client` | Read the saved qBittorrent connection and cleanup options | -| `POST /api/settings/download-client` | Save qBittorrent connection and cleanup options | -| `DELETE /api/settings/download-client` | Remove the saved download-client connection settings | -| `POST /api/settings/test/download-client` | Test the qBittorrent Web UI URL, credentials, and cleanup options before saving | - -### Streamystats (Jellyfin only) - -| Endpoint | Purpose | -| -------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `GET /api/settings/streamystats` | Read the saved Streamystats base URL | -| `POST /api/settings/test/streamystats` | Test a Streamystats URL using the currently configured Jellyfin API key | -| `POST /api/settings/streamystats` | Save the Streamystats base URL | -| `DELETE /api/settings/streamystats` | Remove the saved Streamystats base URL | -| `GET /api/streamystats/info` | Return the configured Streamystats URL plus the resolved Jellyfin server id used for deep links | -| `GET /api/streamystats/items/:itemId` | Return Streamystats watch-history totals, per-user stats, and episode progress for one Jellyfin item | - -### Tracearr - -| Endpoint | Purpose | -| ------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `GET /api/settings/tracearr` | Read the saved Tracearr base URL, API key, and bound Tracearr server | -| `POST /api/settings/test/tracearr` | Test a Tracearr URL and API key before saving | -| `POST /api/settings/tracearr/servers` | Discover the Tracearr servers available for a URL and API key so the settings UI can populate the selector | -| `POST /api/settings/tracearr` | Save the Tracearr connection settings | -| `DELETE /api/settings/tracearr` | Remove the saved Tracearr connection settings | - -`server_id` is optional on `POST /api/settings/tracearr`. Leave it out and Maintainerr picks the Tracearr server that tracks your media server; send it only when Tracearr has several servers of that type. Either way the save is refused if no server matches, or if the one you sent tracks a different media server. - -### Overlays - -| Endpoint | Purpose | -| -------------------------------------------- | ----------------------------------------------------------------------------- | -| `GET /api/overlays/settings` | Read global overlay settings | -| `PUT /api/overlays/settings` | Update global overlay settings | -| `GET /api/overlays/sections` | List media server library sections used by the template preview picker | -| `GET /api/overlays/random-item` | Get a random media item for poster-template preview | -| `GET /api/overlays/random-episode` | Get a random episode for title-card preview | -| `GET /api/overlays/poster` | Proxy media artwork for template preview | -| `GET /api/overlays/status` | Read the latest overlay processing status | -| `POST /api/overlays/process` | Run overlay processing for all eligible collections | -| `POST /api/overlays/process/:collectionId` | Run overlay processing for one collection | -| `POST /api/overlays/revert/:collectionId` | Revert overlays for one collection | -| `DELETE /api/overlays/reset` | Revert all overlays | -| `GET /api/overlays/fonts` | List available fonts | -| `GET /api/overlays/fonts/:name` | Read a bundled or uploaded font file | -| `POST /api/overlays/fonts` | Upload a `.ttf`, `.otf`, or `.woff` font | -| `GET /api/overlays/images` | List uploaded overlay image assets | -| `GET /api/overlays/images/:name` | Read an uploaded overlay image asset | -| `POST /api/overlays/images` | Upload a `.png`, `.jpg`/`.jpeg`, or `.webp` image for template image elements | -| `DELETE /api/overlays/images/:name` | Delete an uploaded overlay image asset | -| `GET /api/overlays/templates` | List overlay templates | -| `GET /api/overlays/templates/:id` | Fetch one template | -| `POST /api/overlays/templates` | Create a template | -| `PUT /api/overlays/templates/:id` | Update a template | -| `DELETE /api/overlays/templates/:id` | Delete a non-preset template | -| `POST /api/overlays/templates/:id/duplicate` | Clone a template into an editable copy | -| `POST /api/overlays/templates/:id/default` | Set a template as the default for its mode | -| `POST /api/overlays/templates/:id/export` | Export a template as JSON | -| `POST /api/overlays/templates/import` | Import a template from JSON | -| `POST /api/overlays/templates/:id/preview` | Render a server-side preview of a template on real artwork | - -`POST /api/overlays/process` accepts an optional `{ force: true }` body to reapply overlays even when the saved day-count state is already current. Its run summary always reports `processed`, `reverted`, `skipped`, and `errors`. - -`POST /api/overlays/process` and `DELETE /api/overlays/reset` both return `409 Conflict` if another overlay-processing run is already active. - -### Storage Metrics - -| Endpoint | Purpose | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `GET /api/storage-metrics` | Return aggregated disk usage, instance health, collection-size summaries, and cumulative cleanup totals | -| `GET /api/storage-metrics/library-sizes` | Compute per-library sizes on demand; potentially slow on large libraries | - -`GET /api/storage-metrics` includes `cleanupTotals` counters for `itemsHandled`, `moviesHandled`, `showsHandled`, `seasonsHandled`, and `episodesHandled`, plus reclaimed-byte totals in `bytesHandled`, `movieBytesHandled`, `showBytesHandled`, `seasonBytesHandled`, and `episodeBytesHandled`. - -The same response also includes `collectionSummary` type breakdowns for `movieSizeBytes`, `showSizeBytes`, `seasonSizeBytes`, `episodeSizeBytes`, and per-type reclaimable collection counts such as `reclaimableMovieCount`. - -Collection payloads carry an optional `mediaServerSort` key. It stores the collection's saved media-server sort order as `{field}.{order}` (for example `deleteSoonest.asc`) when the connected server supports collection sorting. +`status` is `OK` or `NOK`, and `code` is `1` or `0`. A successful call returns `status: "OK"` and `code: 1`. + +**This is the single easiest thing to get wrong.** Check the body, not the status line. Where an endpoint behaves this way it is stated in its status code table. + +Some areas use a different envelope with `code` and `result`, or `code`, `result` and `message`. The field names are given per endpoint. + +A third pattern is worth knowing: several read endpoints return `200` with an **empty body** when something failed, which is not the same as an empty array or object. Those are flagged too. + +### POST returns 201 + +Almost no endpoint overrides the default status, so a successful `POST` answers **`201`**, not `200`, even where an annotation in the generated OpenAPI document says otherwise. `PATCH`, `PUT` and `DELETE` answer `200`. + +### Error bodies + +There is no global error handler, so error bodies are framework defaults. A denied access check produces: + +```json +{ "statusCode": 403, "message": "Forbidden resource", "error": "Forbidden" } +``` + +### Secrets + +`GET /api/settings` masks nine secret fields. The per-integration read routes under `/api/settings` **do not mask** and return the real stored values, because the settings forms need them in order to save them again. `GET /api/settings` also leaves `apikey` and `download_client_username` in the clear. + +Two further routes hand over secrets wholesale: `GET /api/notifications/configurations` returns every notification credential unmasked, and `GET /api/settings/database/download` streams the entire database with every secret in plaintext. + +There is also **no masked-value detection on writes**. Reading a masked body and posting it back stores the mask over your real secret. + +## Interactive reference + +Your own instance serves a live, interactive reference generated from the running build: + +| URL | What it is | +| ------------------------------------------- | ------------------------ | +| `http:///api/swagger` | Swagger UI | +| `http:///api/swagger-json` | The raw OpenAPI document | + +Both are prefixed by `BASE_PATH` when it is set. + +A snapshot of that document also ships with this site at `static/openapi-spec/maintainerr_api_specs.yaml`. For the instance you are actually running, the live document is the authority. diff --git a/docs/api/app-and-health.md b/docs/api/app-and-health.md new file mode 100644 index 000000000..3ad9f13d8 --- /dev/null +++ b/docs/api/app-and-health.md @@ -0,0 +1,175 @@ +--- +slug: /api/app-and-health +title: App and Health API +description: Application status, version, time zone, GitHub releases, health probes, and task status. +--- + +Endpoints for the running build itself: what version it is, what time zone it resolved, whether it is healthy, and whether a scheduled task is running. + +The health probes are the one part of the API that reports failure with a real HTTP error code. Everything else here answers `200` whatever happens. See [API conventions](../API.md#api-conventions) for the rules that apply across the API. + +## App + +### `GET /api/app/status` + +**Report the running build's version and whether a newer build exists upstream.** + +Reads the build environment and then asks GitHub whether a newer build exists. Release builds compare their version against the latest GitHub release. Other builds compare their commit against the head of `main` or `development`, and skip the network call entirely when the build carries no commit id. + +Response: + +```json +{ + "status": 1, + "version": "3.25.0", + "commitTag": "latest", + "updateAvailable": false +} +``` + +| Field | Meaning | +| ----------------- | ---------------------------------------------------------------------------------------------------- | +| `status` | `1` normally, `0` when the version lookup threw | +| `version` | The package version for a release build, otherwise `tag-shortsha`, for example `development-bd8a1e0` | +| `commitTag` | `local` outside production. In production it is the image tag, or empty for a non-release build | +| `updateAvailable` | Whether a newer build was found | + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------------------------- | +| `200` | Always. On an internal error the body falls back to `status: 0`, `version: "0.0.1"`, `updateAvailable: false` | + +Two traps. The handler returns the payload as a string, so the response is sent with `Content-Type: text/html; charset=utf-8` even though the body is valid JSON. A client that dispatches on content type will need to parse it manually. And the update check fails open: an unreachable GitHub gives `updateAvailable: false`, which is indistinguishable from being up to date. + +### `GET /api/app/timezone` + +**Return the IANA time zone the server process resolved.** + +Reports the time zone the process is running in, which is whatever `TZ` resolved to inside the container. Nothing is read from the database. + +The response is a bare string, not JSON: + +```text +Europe/Stockholm +``` + +| Status | Cause | +| ------ | --------------------------------------- | +| `200` | Always. The handler has no failure path | + +Because the body is a plain string it is sent as `Content-Type: text/html; charset=utf-8` and is **not** valid JSON. Clients that always parse JSON will fail on it. When the host time zone cannot be determined the value is `UTC`. + +### `GET /api/app/releases` + +**Return the 10 most recent Maintainerr GitHub releases.** + +Proxies GitHub's release listing for the Maintainerr repository and returns the raw GitHub objects with no filtering or reshaping. This is what fills the releases block on the Settings, About page. + +Response is a JSON array of GitHub release objects. Each carries at least `tag_name`, `name`, `body`, `html_url`, `created_at` and `published_at`, plus every other field GitHub sends, such as `id`, `draft`, `prerelease`, `tarball_url` and the author and assets objects. + +| Status | Cause | +| ------ | ------------------------------------------------------------ | +| `200` | Always, including when GitHub is unreachable or rate limited | + +This fails open: a GitHub outage returns `200` with an empty array, which is indistinguishable from a repository with no releases. The failure is only logged at debug level, so at the default log level nothing is written about it at all. + +Results are cached in memory for 24 hours and mirrored to a cache file in the data directory. GitHub is contacted unauthenticated, which is 60 requests an hour, unless `GITHUB_TOKEN` is set. + +## Health + +These three are the endpoints to point orchestrators and uptime monitors at. All are read-only, and the only thing gating readiness is the database. + +### `GET /api/health/live` + +**Liveness probe that answers 200 whenever the process is running.** + +Never touches the database. It reports only that the HTTP server answered, so a wedged process can be told apart from a database blip and a restart loop is not triggered by a transient database fault. Use it as a Kubernetes `livenessProbe`. + +Response: + +```json +{ + "status": "ok", + "uptimeSeconds": 1234, + "timestamp": "2026-06-05T12:00:00.000Z" +} +``` + +`status` is always the literal `"ok"`. There is no `database` field, unlike the readiness payload. + +| Status | Cause | +| ------ | --------------------------------------------------------------------------------- | +| `200` | The only outcome. It can only fail to answer if the process is not serving at all | + +Do not use this as an availability signal. It keeps returning `200` while `/api/health/ready` returns `503`. + +### `GET /api/health/ready` + +**Readiness probe that pings the database and returns 503 when it is unreachable.** + +Runs a `SELECT 1` against the SQLite database. This is the endpoint the bundled Docker `HEALTHCHECK` calls, and the one to use as a Kubernetes `readinessProbe`. + +Response: + +```json +{ + "status": "ok", + "uptimeSeconds": 1234, + "database": "ok", + "timestamp": "2026-06-05T12:00:00.000Z" +} +``` + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `200` | `SELECT 1` succeeded. `status` is `ok`, `database` is `ok` | +| `503` | `SELECT 1` threw, meaning the database file is missing, locked or corrupt, or the datasource never initialised. `status` is `degraded`, `database` is `unreachable` | + +The `503` body carries the same four fields rather than a generic error envelope, so a monitor can read the reason. + +This is the exception to the usual Maintainerr pattern: it fails closed with a real `503` instead of a `200` envelope. Note the limits of what it proves. `SELECT 1` shows only that the database handle answers. It does not check that migrations ran, that any table exists, or that any media server, `*arr` or Seerr upstream is reachable. External integrations are excluded on purpose so a transient upstream outage does not take Maintainerr out of rotation. `uptimeSeconds` is process uptime, not time since bootstrap finished. + +### `GET /api/health` + +**Combined health check that mirrors the readiness probe.** + +An exact alias of `GET /api/health/ready`: same database check, same payload, same `503`. It exists for simple monitors that do not distinguish liveness from readiness. + +| Status | Cause | +| ------ | -------------------- | +| `200` | `SELECT 1` succeeded | +| `503` | `SELECT 1` threw | + +## Tasks + +### `GET /api/tasks/{id}/status` + +**Report whether a named scheduled task is currently running and since when.** + +Looks the task up by name and returns its running flag. Nothing is read from the database and the cron schedule is not consulted. + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ------------------------------------------------------------------------- | +| `id` | string | Yes | The task **name**, not a numeric id. Matched exactly and case-sensitively | + +Valid names are `Collection Handler`, `Collection Log Cleaner`, `Rule Maintenance`, `Overlay Handler`, `Notification Timer`, `Version Notification` and `Telemetry Ping`. They contain spaces, so strict clients must percent-encode the segment. + +Response: + +```json +{ + "time": "2026-06-05T12:00:00.000Z", + "running": false, + "runningSince": null +} +``` + +`time` is the server clock at the moment of the call, which lets a client discard out-of-order updates. `runningSince` is `null` while the task is idle. + +| Status | Cause | +| ------ | ------------------------------------------- | +| `200` | The task exists, running or not | +| `404` | No task is registered under that exact name | + +The state is per process and in memory, so after a restart every task reports `running: false` until it next runs. Tasks are registered during bootstrap with retries, so a request in the first seconds after start can `404` for a task that will exist shortly. + +`Rule Handler` is not a valid name. Rule execution moved to per rule group cron jobs that never enter this map. Use [`GET /api/rules/execute/status`](./rules.md#get-apirulesexecutestatus) for rule run state instead. diff --git a/docs/api/collections.md b/docs/api/collections.md new file mode 100644 index 000000000..d46a104e2 --- /dev/null +++ b/docs/api/collections.md @@ -0,0 +1,825 @@ +--- +slug: /api/collections +title: Collections API +description: Maintainerr collections, membership, bulk media actions, handling, posters and logs. +--- + +Maintainerr's own collections: the rows it keeps in its database, the membership it tracks, and the actions it runs against that membership. See the [Collections](../Collections.md) page for what the feature does. + +These are not the same thing as collections on your media server. Those live under [`/api/media-server`](./media-server.md). A Maintainerr collection usually has a linked media server collection, and Maintainerr reconciles the two, but the records are separate. + +See [API conventions](../API.md#api-conventions) for the rules that apply across the API. + +## Four words that mean very different things + +This is the single easiest thing to get wrong on this page. + +| Term | What it does | Deletes media files? | +| --------------------- | ------------------------------------------------------------------- | ----------------------------------------- | +| **Remove** | Ends an item's membership of a collection | No | +| **Handle** | Runs the collection's configured action on an item, then removes it | **Yes**, if the action is a delete action | +| **Deactivate** | Tears down the media server collection and **wipes all membership** | No | +| **Remove collection** | Deletes the Maintainerr collection **and its rule group** | No | + +Only **handle** touches media files. Everything else is membership and bookkeeping. + +## The deletion timer + +Every membership row carries an `addDate`, set to the day the item was added. The collection handler acts on an item once `addDate` is at least `deleteAfterDays` in the past. + +:::danger A null deleteAfterDays means "due immediately" +The handler reads a missing `deleteAfterDays` as `0`, so **every member of such a collection is due right now**. If that collection's action is a delete action, the next run deletes those files. + +Note this disagrees with the UI, which shows no leaving date at all for the same value. Set `deleteAfterDays` explicitly on any collection whose action deletes. +::: + +Adding an item starts its timer from that day. Removing and re-adding it restarts the timer from scratch. + +## Reading collections + +### `GET /api/collections` + +**List Maintainerr collections with a two-item media preview and a true member count.** + +| Parameter | Type | Required | Description | +| ----------- | ----- | -------- | -------------------------------------------------------------------------------------------- | +| `libraryId` | query | No | Media server library id to filter on. Not validated | +| `typeId` | query | No | `movie`, `show`, `season` or `episode`. Not validated: an unrecognised value matches nothing | + +The two filters are combined with AND. + +Each row is the full collection record plus two extra keys: `media`, holding at most **two** preview rows, and `mediaCount`, the real member count. + +| Status | Cause | +| ------------------------ | ---------------------------------------------------------------------- | +| `200` | The list | +| `200` with an empty body | The read threw, or artwork enrichment could not reach the media server | + +:::caution The whole list can vanish with a 200 +Preview rows that have no artwork are enriched on the fly, and that step needs a working media server. If no media server is configured, or a switch is in progress, the **entire response** comes back as an empty `200`. + +Clients must handle a non-array response. Use [`GET /api/collections/overlay-data`](#get-apicollectionsoverlay-data) if you want a list that never contacts the media server. +::: + +`media` is a preview, not membership. Use `mediaCount` for the size and the paged content route for the members. + +Size fields are stored as big integers and may arrive as numeric strings rather than numbers. Do not assume the JSON type. + +### `GET /api/collections/collection/{id}` + +**Fetch one collection's settings row by database id.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------------------- | +| `id` | integer | Yes | Maintainerr collection id | + +| Status | Cause | +| ------------------------ | ----------------------------------------- | +| `200` | The row | +| `200` with an empty body | The id does not exist, or the query threw | +| `400` | `id` is not an integer | + +There is no `404`, so check for an empty body. This route never populates a `media` array, so do not read membership from it. + +### `GET /api/collections/overlay-data` + +**List collections with their complete media membership.** + +Same filters as `GET /api/collections`, but instead of a two-item preview every membership row is attached, with `mediaCount` equal to the real length. + +| Parameter | Type | Required | Description | +| ----------- | ----- | -------- | -------------------------------------- | +| `libraryId` | query | No | Media server library id to filter on | +| `typeId` | query | No | `movie`, `show`, `season` or `episode` | + +| Status | Cause | +| ------------------------ | -------------- | +| `200` | The list | +| `200` with an empty body | The read threw | + +This never contacts the media server, so unlike `GET /api/collections` it cannot be emptied by an unconfigured one. It returns no artwork and no media metadata, only the membership rows. + +Compute a leaving date as `addDate` plus `deleteAfterDays`, treating a null `deleteAfterDays` as "no leaving date". Remember the handler does not use that convention and treats null as `0`. + +:::caution No paging and no cap +This is the only route that returns full membership for every collection at once. On a large install it serialises essentially the entire membership table in one response. +::: + +### `GET /api/collections/media` + +**List the raw membership rows for one collection.** + +| Parameter | Type | Required | Description | +| -------------- | -------------- | -------- | ------------------------------------------------- | +| `collectionId` | query, integer | **Yes** | Maintainerr collection id. Omitting it is a `400` | + +Response: + +```json +[ + { + "id": 1, + "collectionId": 1, + "mediaServerId": "12345", + "tmdbId": 550, + "addDate": "2026-06-05T00:00:00.000Z", + "image_path": null, + "isManual": true, + "includedByRule": false, + "manualMembershipSource": "local", + "sizeBytes": null, + "ruleEvaluationFailed": false + } +] +``` + +| Status | Cause | +| ------------------------ | ----------------------------------------------- | +| `200` | The rows. An unknown collection id returns `[]` | +| `200` with an empty body | The read threw | +| `400` | `collectionId` is missing or not an integer | + +Prefer `includedByRule` and `manualMembershipSource` over `isManual`, which is a derived mirror. A row can be **both** rule-owned and manual at once. + +`sizeBytes` is filled in lazily and is null for freshly added items. There is no paging, so a large collection returns every row in one response. + +### `GET /api/collections/media/count` + +**Count membership rows, for one collection or across all of them.** + +| Parameter | Type | Required | Description | +| -------------- | -------------- | -------- | ----------------------------------------------- | +| `collectionId` | query, integer | No | Omit to count every row across every collection | + +The response is a bare JSON number. + +| Status | Cause | +| ------ | ----------------------------------------------- | +| `200` | The count. An unknown collection id returns `0` | +| `400` | `collectionId` is present but not an integer | +| `500` | The database read failed | + +This counts membership rows, not distinct items. An item in three collections counts three times in the unscoped form. + +### `GET /api/collections/media/{id}/content/{page}` + +**Return one page of a collection's members, with media server metadata attached.** + +| Parameter | Type | Required | Description | +| ----------- | -------------- | -------- | --------------------------------------------------------------------------------------------- | +| `id` | path, integer | Yes | Maintainerr collection id | +| `page` | path, integer | Yes | 1-based page number. Not lower-bounded, so page `0` produces a negative offset | +| `sort` | query | No | `title`, `airDate`, `rating`, `watchCount`, `manual`, `excluded`, `studio` or `deleteSoonest` | +| `sortOrder` | query | No | `asc` or `desc` | +| `size` | query, integer | No | Page size, default `25`. **No upper bound** | + +Response is `totalSize` plus `items`, where each item is the membership row plus a `mediaData` object holding the media server metadata. + +| Status | Cause | +| ------------------------ | ---------------------------------------------------------------------------------------------------- | +| `200` | The page | +| `200` with an empty body | Anything threw, including no media server configured, a switch in progress, or an unreachable server | +| `400` | `id`, `page` or `size` is not an integer, or `sort` or `sortOrder` is outside its allowed values | + +:::caution Two sorting paths with different costs and different totals +Omitting `sort`, or using `deleteSoonest`, pages in the database and is cheap. + +**Any other sort loads the entire collection**, sorts it in memory, and only then returns your page. On a large collection that is expensive. + +The two paths also report `totalSize` differently. The cheap path counts every row, and items the media server could not resolve are skipped, so a page can be shorter than `size` while `totalSize` stays high. The sorting path counts only rows it could resolve. The same collection reports two different totals depending on how you sort it. +::: + +Items the media server does not answer for are skipped, never deleted. This route cannot tell a missing item from a failed lookup. + +Despite being a read, there is one write here: if the collection's linked media server collection is confirmed gone, the stale link is cleared. A lookup that merely fails keeps the link. + +### `GET /api/collections/logs/{id}/content/{page}` + +**Return one page of a collection's activity log.** + +| Parameter | Type | Required | Description | +| --------- | -------------- | -------- | --------------------------------------------------------------------- | +| `id` | path, integer | Yes | Maintainerr collection id | +| `page` | path, integer | Yes | 1-based page number | +| `search` | query | No | Substring match on the log message. An empty value matches everything | +| `sort` | query | No | `ASC` or `DESC`. Defaults to `DESC`, newest first | +| `filter` | query | No | Log type: `0` collection, `1` media, `2` rules. Not validated | +| `size` | query, integer | No | Page size, default `25`. No upper bound | + +Response is `totalSize` plus `items`, each holding `id`, `timestamp`, `message`, `type` and `meta`. + +| Status | Cause | +| ------ | --------------------------------------------------------------------- | +| `200` | The page. An unknown collection id returns an empty page, not a `404` | +| `400` | `id`, `page` or `size` is not an integer | +| `500` | `sort` is not a usable sort direction. Only `ASC` and `DESC` are safe | + +Log retention is governed by the collection's `keepLogsForMonths` setting and a cleanup task, so old entries disappear on their own. There is no way to delete a single entry. + +### `GET /api/collections/exclusions/{id}/content/{page}` + +**Return one page of the exclusions that apply to a collection.** + +| Parameter | Type | Required | Description | +| ----------- | -------------- | -------- | ------------------------------------------------------------------------- | +| `id` | path, integer | Yes | Maintainerr **collection** id, not the rule group id | +| `page` | path, integer | Yes | 1-based page number | +| `sort` | query | No | Same sort keys as the content route. Omitted means newest exclusion first | +| `sortOrder` | query | No | `asc` or `desc` | +| `size` | query, integer | No | Page size, default `25`. No upper bound | + +Returns `totalSize` plus `items`, each an exclusion row with a `mediaData` object attached. + +| Status | Cause | +| ------------------------ | ------------------------------------------------------------------------------- | +| `200` | The page, including an empty one when no rule group is linked to the collection | +| `200` with an empty body | The read threw, including an unreachable or unconfigured media server | +| `400` | A path or query parameter failed validation | + +The type filter widens deliberately. A season rule group also lists show exclusions, and an episode rule group also lists show and season exclusions, because a parent exclusion suppresses its children. Global exclusions always appear. + +`sort=manual` and `sort=excluded` are accepted but do nothing here, because this route does not attach that state. + +Listing an exclusion is not the same as applying it. Exclusions only take effect on the next rule run. + +## Creating and changing collections + +### `POST /api/collections` + +**Create a collection row, and when media is supplied the matching media server collection.** + +Request body has a required `collection` object and an optional `media` array: + +```json +{ + "collection": { + "type": "movie", + "libraryId": "1", + "title": "Example collection", + "isActive": true, + "arrAction": 0, + "deleteAfterDays": 30 + }, + "media": [{ "mediaServerId": "12345" }] +} +``` + +Required inside `collection` are `type`, `libraryId`, `title`, `isActive` and `arrAction`. `arrAction` must be the **number**, not the name: `0` delete, `1` unmonitor and delete all, `2` unmonitor and delete existing, `3` unmonitor, `4` do nothing, `5` delete show if empty, `6` unmonitor show if empty, `7` change quality profile. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------ | +| `201` | Always on a valid body, **including when the create failed internally**. The body is empty | +| `400` | Validation failed | + +:::caution Nothing about the outcome reaches you +The response is an empty `201` whether it worked or not, and the new id is not returned. A manual collection whose named media server collection could not be found writes nothing at all and still answers `201`. Discover the result with `GET /api/collections`. +::: + +:::danger deleteAfterDays: null becomes 0 +`deleteAfterDays` is coerced, and an explicit JSON `null` becomes `0`, which the handler treats as "due immediately". Omitting the field stores no value, which the handler **also** treats as `0`. + +Combined with `arrAction: 0`, that arms media deletion on the next handler run. Always set `deleteAfterDays` explicitly. +::: + +A collection created here has no rule group, so nothing will ever add to or remove from it automatically, but the handler still acts on its members once the timer elapses. + +No events are emitted by this route, so no "media added" notification is sent. + +### `PUT /api/collections` + +**Overwrite a collection's settings, re-pushing metadata to the media server or tearing the link down.** + +Same field shape as the create route's `collection` object, but `id` is required, along with `type`, `libraryId`, `title`, `isActive` and `arrAction`. + +| Status | Cause | +| ------------------------ | ----------------------------------------------------- | +| `200` | Saved. The body is the saved row under `dbCollection` | +| `200` with an empty body | Anything threw | +| `400` | Validation failed | + +:::warning Destructive +Changing `type`, `libraryId`, `manualCollection` or `manualCollectionName` **deletes the linked collection on your media server**, or, when a sibling rule group shares it, strips this collection's items out of it. The link is then cleared. + +Local membership rows survive, and the next add recreates the server collection and re-syncs them. The media server collection itself is not recoverable, though Maintainerr rebuilds an equivalent one. + +If the media server cannot be reached, nothing is pushed and the link is kept on purpose. +::: + +:::danger An omitted field can trigger that teardown +The change comparison runs against the **raw body**, not the merged result. On a stored manual collection, a `PUT` that simply omits `manualCollection` reads as a change and falls into the teardown branch. + +Send the full object, including every field you want unchanged. +::: + +This is a full replace, not a partial update. Omitted optional keys keep their stored value, but an explicit `null` overwrites. + +There is no existence check and no `404`. A `PUT` with an id that does not exist **creates** a collection instead of failing. + +Sending `keepInMaintainerrOnly` does nothing. It is stripped from the body, and the stored value is what decides whether metadata is pushed. + +An empty or whitespace `sortTitle` is stored as null. + +### `GET /api/collections/activate/{id}` + +**Mark a collection and its rule group active again.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------------------- | +| `id` | integer | Yes | Maintainerr collection id | + +| Status | Cause | +| ------ | ---------------------------------------------------------- | +| `200` | Always, whether or not anything changed. The body is empty | +| `400` | `id` is not an integer | + +Note this is a `GET` that changes state. + +Activating does **not** restore membership. A previous deactivate deleted every membership row, so the collection comes back empty and stays empty until the next rule run repopulates it. + +The response can never tell you whether it worked. An unknown id changes nothing and still answers `200`. + +### `GET /api/collections/deactivate/{id}` + +**Deactivate a collection, tearing down its media server collection and wiping its membership.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------------------- | +| `id` | integer | Yes | Maintainerr collection id | + +| Status | Cause | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `200` with `status: "OK"` | Deactivated | +| `200` with `status: "NOK"` | The collection is shared with another rule group and its items could not be taken out, so nothing changed. Or something threw, including an unknown id | +| `400` | `id` is not an integer | + +:::warning Destructive +Despite the name, this is not a reversible pause. It **deletes every membership row** for the collection, and deletes the collection on your media server, or removes this collection's items from one shared with a sibling rule group. + +**Membership cannot be restored.** Calling activate afterwards only flips the flags: the collection comes back empty and stays empty until the next rule run rebuilds it. + +No media files are deleted. +::: + +Also note this is a `GET` that changes state, and that the failure envelope arrives with a `200`, so you must inspect `status`. + +If the media server delete failed for a collection nobody shares, the deactivation still proceeds and the link is kept, so the collection may be left standing on your media server. + +### `POST /api/collections/removeCollection` + +**Delete a collection, its media server collection, and everything that cascades from it.** + +Request body: + +```json +{ "collectionId": 1 } +``` + +| Status | Cause | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `201` with `status: "OK"` | Deleted, **or the id did not exist** | +| `201` with `status: "NOK"` | The media server collection could not be removed and nothing local was deleted, or the row delete failed, or something else threw | +| `400` | Validation failed | + +:::warning Destructive +Deletes the Maintainerr collection row and, by cascade, **its rule group**, along with that group's rules, its notification links, all membership rows and the whole collection log. Deleting a collection therefore deletes its rule. + +It also deletes the collection on your media server, reverts any overlays it applied by restoring original posters, and deletes the stored custom poster file from disk. + +**None of this is recoverable.** No media files are deleted. +::: + +A failed media server teardown is a hard stop: the row survives and the message carries the server's own explanation, such as Plex naming its media deletion setting. Fix that and retry. + +Deleting a collection that does not exist reports success, so the envelope is not proof anything existed. + +Rule-group-scoped exclusions are left behind as orphans when the group cascades away. + +## Membership + +### `POST /api/collections/add` + +**Add media server items to one collection, creating or repairing the media server collection as needed.** + +Request body: + +```json +{ + "collectionId": 1, + "media": [{ "mediaServerId": "12345" }], + "manual": false +} +``` + +| Field | Type | Required | Description | +| -------------- | ------- | -------- | -------------------------- | +| `collectionId` | number | Yes | Target collection | +| `media` | array | Yes | Items to add. May be empty | +| `manual` | boolean | No | Defaults to `false` | + +| Status | Cause | +| ------ | --------------------------------------------------------------------------------------------------------------------- | +| `201` | Always on a valid body, **including when the collection does not exist or every item was refused**. The body is empty | +| `400` | Validation failed | + +:::warning Destructive +Adding an item **starts its deletion timer**. Once `deleteAfterDays` elapses, the collection handler runs the collection's action on it, which for a delete action permanently removes the media files. + +The membership itself is reversible: remove the item to end it. +::: + +`manual` matters. The default `false` marks the item as rule-owned, so the owning rule group's next run can remove it again. Setting `true` marks it as a manual member, which survives rule runs. + +This route takes raw media server ids only, with no hierarchy resolution. Use `POST /api/collections/media/add` when a show id needs expanding into seasons or episodes. + +The response hides every failure. A nonexistent collection id and server-refused items both answer an empty `201`. + +### `POST /api/collections/media/add` + +**Manually add one item, with its resolved hierarchy, to a collection, or remove it.** + +Despite the path, this handles both directions, chosen with `action`. + +Request body for an add: + +```json +{ + "action": 0, + "mediaId": "12345", + "collectionId": 1, + "context": { "id": "12345", "type": "show" } +} +``` + +| Field | Type | Required | Description | +| -------------- | ------ | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `action` | number | Yes | `0` adds, `1` removes | +| `mediaId` | string | Yes | The item to act on | +| `context` | object | Yes | `id` and `type`, saying which level of the hierarchy was acted on. May also carry `index` and `parentIndex` | +| `collectionId` | number | For an add | Required to add. **Omit it on a removal to remove from every collection** | + +The `context` is expanded against the media server into the ids the target collection can actually hold. A show id becomes its season ids for a season collection, or its episode ids for an episode collection. + +| Status | Cause | +| ------ | ----------------------------------------------------------------------------------- | +| `201` | Success. The body is the collection, or empty on a global removal | +| `400` | Validation failed, or the item resolved to nothing the collection can take | +| `404` | `Collection {id} not found` | +| `502` | The media server could not resolve the item, refused some ids, or the update failed | +| `503` | A media server switch is in progress, or credentials are not saved | +| `500` | No media server type configured | + +:::warning Destructive +Adding **starts the deletion timer**, so an item added to a collection whose action deletes will eventually have its files deleted. + +A manual add is not an exclusion. It stops rules removing the item, but it does not stop the collection's own action. + +Removing ends membership only. No media files, `*arr` entries or Seerr requests are touched. +::: + +A `502` does not mean nothing happened. Items the server accepted were still added. + +On a global removal the item **and every descendant** are removed from every collection, so removing a show also drops its seasons and episodes. + +### `POST /api/collections/media/bulk` + +**Add or remove a selection of items to or from one collection, or from every collection.** + +This is the bulk form, and the one the web UI actually uses. It backs the add and remove media modal described in [Collections](../Collections.md#add-remove-media-modal). + +Request body: + +```json +{ + "mediaIds": ["12345", "12346"], + "collectionId": 1, + "action": 0, + "mediaType": "movie", + "context": { "id": "12345", "type": "season" } +} +``` + +| Field | Type | Required | Description | +| -------------- | -------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `mediaIds` | string[] | Yes | 1 to 250 media server ids | +| `action` | number | Yes | `0` adds, `1` removes | +| `mediaType` | string | Yes | `movie`, `show`, `season` or `episode`. Required so the hierarchy can be resolved without a lookup per item | +| `collectionId` | number | For an add | Omit on a removal to mean every collection. **Not coerced**, so a string id is a `400` | +| `context` | object | No | Narrows a one-item selection to a single season or episode. Sending it with more than one id is an error | + +Response: + +```json +{ + "results": [ + { "mediaId": "12345", "code": 1 }, + { + "mediaId": "12346", + "code": 0, + "message": "Failed - refused by the media server" + } + ] +} +``` + +There is one result per **deduplicated** id, so repeating an id yields fewer results than you sent. `code` is `1` for success and `0` for failure. + +| Status | Cause | +| ------ | ------------------------------------------------------------------ | +| `201` | Returned **even when every item failed**. Check `results[].code` | +| `400` | Validation failed, or an add was requested with no `collectionId` | +| `404` | `Collection {id} not found` | +| `503` | A media server switch is in progress, or credentials are not saved | +| `500` | No media server type configured | + +:::warning Destructive +Adding **starts the deletion timer** for every item added. A removal with no `collectionId` iterates every collection in the database. + +No media files, `*arr` entries or Seerr requests are touched. +::: + +The 250 limit applies to one request, not to how much you can select. The web UI sends 25 ids per request and splits larger selections across several calls, so only direct API callers reach it. + +On a removal, membership is re-read afterwards and any id still present is reported as refused, so a success here really does mean the row is gone. + +### `POST /api/collections/remove` + +**Remove media items from one collection, locally and on the media server.** + +Request body: + +```json +{ "collectionId": 1, "media": [{ "mediaServerId": "12345" }] } +``` + +| Status | Cause | +| ------ | ---------------------------------------------------------------------------------------------------------------- | +| `201` | Always on a valid body, including for an unknown collection id or an unreachable media server. The body is empty | +| `400` | Validation failed | + +:::warning Destructive +Deletes the membership rows and removes the items from the linked media server collection. **No media files are deleted**, no `*arr` entity is touched, and no Seerr request is removed. + +Reversible by re-adding, but the deletion timer restarts from scratch on re-add. + +If this empties an automatic collection, the collection on your media server is **deleted** as a side effect, or merely unlinked when a sibling rule group shares it. +::: + +Removal is not sticky. It adds no exclusion, so the owning rule group's next run can re-add the item immediately. Use an [exclusion](./rules.md#post-apirulesexclusionsbulk) if you want it to stay out. + +All failures are silent: the response is an empty `201` either way. + +### `DELETE /api/collections/media` + +**Remove one item from a single collection, or from every collection.** + +| Parameter | Type | Required | Description | +| -------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------- | +| `mediaId` | query | No | Media server item id. Not validated. **Omitting it is a silent no-op that still reports success** | +| `collectionId` | query, integer | No | Omit, or send `0`, to remove from every collection | + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `200` | Always. With `collectionId`, the body is the collection, or empty on an internal failure. Without it, the body is a `status` and `code` envelope | +| `400` | `collectionId` is present but not an integer | + +:::warning Destructive +Deletes the membership rows and removes the item from the linked media server collection. **No media files are deleted.** + +Reversible by re-adding, though the deletion timer restarts. If this empties an automatic collection, the media server collection is deleted. +::: + +The two branches report failure differently: the single-collection branch signals it with an empty body, and the all-collections branch with `status: "NOK"`. + +There is no hierarchy expansion here, so removing a show from a season collection removes nothing. + +## Handling + +These are the routes that can permanently delete media. + +### `POST /api/collections/handle` + +**Start the collection handler run that executes every collection's action on its due media.** + +Takes no request body. + +| Status | Cause | +| ------ | ------------------------------------------------------------------ | +| `201` | The run was **started**. It does not mean it finished or succeeded | +| `409` | `The collection handler is already running` | + +:::danger Destructive: this deletes media files +This runs every active collection's configured action against every member whose deletion timer has elapsed. + +For `DELETE`, `UNMONITOR_DELETE_EXISTING`, `UNMONITOR_DELETE_ALL` and `DELETE_SHOW_IF_EMPTY` that means **permanently deleting the media files from disk** through Radarr, Sonarr or Sportarr, removing the entity from that `*arr`, optionally adding an import list exclusion, and removing matching downloads from your download client. With `cleanupLeftoverFolders` on, the stranded folder and its sidecars are deleted too. + +With no `*arr` configured and a delete action, Maintainerr calls the media server's own delete instead. + +With `forceSeerr` on, the item's Seerr request and media record are deleted as well. + +**None of the file deletion is reversible.** + +`UNMONITOR`, `UNMONITOR_SHOW_IF_EMPTY` and `CHANGE_QUALITY_PROFILE` leave files alone. +::: + +The run is fire and forget, so a `201` only means it was accepted. Watch the [events stream](./metadata-and-storage.md#get-apieventsstream) or the collection logs for the outcome. + +The `409` only covers the handler itself. A request during a rule run succeeds and simply queues behind it. + +Several things protect an item: rows whose rule evaluation failed on the last run are skipped unless they are manual members, items currently being streamed are deferred to the next run, and excluded items are dropped. Exclusions are the only thing protecting a manually added member. + +If the media server cannot be reached the entire run is skipped. + +### `POST /api/collections/media/handle` + +**Immediately run the collection's action against one item, ahead of its deletion timer.** + +Request body: + +```json +{ "collectionId": 1, "mediaId": "12345" } +``` + +`collectionId` is **not** coerced here, so it must be a number, unlike the postpone route. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------- | +| `201` | The item was handled, or was pruned because it no longer exists on the media server | +| `400` | Validation failed | +| `404` | `Collection not found` or `Media not found in collection` | +| `409` | The handler or rule queue is already running, **or the action could not be executed** | +| `500` | Something in the chain threw | +| `503` | A media server switch is in progress, or credentials are not saved | + +:::danger Destructive: this deletes media files +The same action chain as the full handler run, applied to one item. With a delete action this **permanently deletes the media files** and is not reversible. + +It ignores the deletion timer entirely, so it acts even on an item that is nowhere near due. There is also no active check, so an item in a deactivated collection can still be handled. +::: + +A `409` does not always mean "busy". It is also the answer when the action could not run at all, including a collection whose action is "do nothing", a library and `*arr` mismatch, and an `*arr` that could not be reached. In those cases the item stays in the collection. + +The whole chain runs inside the request, so a large show can hold the connection open for a long time, and holds the shared execution lock for the same duration, blocking rule runs. + +### `POST /api/collections/media/postpone` + +**Push out, or fully reset, the deletion timer for one item.** + +Request body: + +```json +{ "collectionId": 1, "mediaId": "12345", "days": 14 } +``` + +| Field | Type | Required | Description | +| -------------- | ------ | -------- | ---------------------------------------------------------------------- | +| `collectionId` | number | Yes | Coerced, so a string id is accepted | +| `mediaId` | string | Yes | Media server item id | +| `days` | number | No | Between `1` and `3650`. **Omit to restart the full window from today** | + +Response: + +```json +{ + "collectionId": 1, + "mediaServerId": "12345", + "addDate": "2026-06-05T00:00:00.000Z", + "deleteAfterDays": 30, + "deletionDate": "2026-07-05T00:00:00.000Z" +} +``` + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------------------------------- | +| `201` | Success. Note the OpenAPI document says `200`, which is wrong | +| `400` | Validation failed | +| `404` | `Media not found in collection`, returned both when the collection does not exist and when the item is not a member | +| `409` | A run held the lock for the full 30 seconds | + +This is fully reversible: post again with different values. + +It waits up to 30 seconds for the shared execution lock rather than failing fast, because a run already in flight could otherwise delete the item despite the postpone. If the item is handled by that in-flight run, you get a `404`, which is the definite answer. + +When `days` is supplied and the item is already overdue, the new date is measured from the handler's own cutoff rather than the stale date, so the deadline cannot land in the past. + +A postpone is not an exclusion and not a manual add. The rule executor can still remove the item from the collection, and re-adding later resets the timer to that day. + +## Posters + +### `GET /api/collections/{id}/poster` + +**Stream the stored custom poster image for a collection.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------------------- | +| `id` | integer | Yes | Maintainerr collection id | + +Returns `image/jpeg` bytes. Always JPEG, whatever was uploaded. + +| Status | Cause | +| ------ | ----------------------------------------------------------------------------------- | +| `200` | The poster is streamed | +| `400` | `id` is not an integer | +| `404` | `No custom poster set for this collection`. This is the normal answer, not an error | +| `500` | The file disappeared between the check and the read | + +A `HEAD` request works too, which is how the UI probes whether a custom poster is set. + +The collection row is never consulted, so a leftover file for a deleted collection id still streams a `200`. + +These are Maintainerr's local bytes, not whatever your media server is currently showing. The two can differ if another tool overwrote the artwork. + +### `POST /api/collections/{id}/poster` + +**Upload a custom poster, store it locally, and push it to the media server.** + +Send `multipart/form-data` with a single file field named `poster`. + +| Parameter | Type | Required | Description | +| --------- | ------------- | -------- | ------------------------------------------------------------------------- | +| `id` | path, integer | Yes | Maintainerr collection id | +| `poster` | file | Yes | The image. Maximum **500 KB**. Any format that can be decoded is accepted | + +Response: + +```json +{ "pushed": true, "attempted": true } +``` + +| Status | Cause | +| ------ | --------------------------------------------------------------------------- | +| `201` | Success, regardless of whether the media server push worked | +| `400` | No file uploaded, the file is not a valid image, or the field name is wrong | +| `404` | `Collection not found` | +| `413` | The file exceeds 500 KB | +| `500` | The data directory is not writable | + +:::warning Destructive +This **overwrites** two things with no backup: the stored poster file on disk, and the artwork of the linked collection on your media server. Neither previous image is recoverable. +::: + +`attempted: false` means the push was never tried, because the collection has no linked media server collection yet, no media server is reachable, or the server does not support collection posters. The file is still stored and is pushed automatically the first time Maintainerr creates the collection. + +Whatever you upload comes back as JPEG. A PNG or WebP is transcoded and transparency is lost. + +Maintainerr is one writer among several here. This is a single push, not a continuously reapplied overlay, so another tool can overwrite it afterwards. + +### `DELETE /api/collections/{id}/poster` + +**Delete the stored custom poster and ask the media server to refresh its metadata.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------------------- | +| `id` | integer | Yes | Maintainerr collection id | + +Response: + +```json +{ "cleared": true, "refreshRequested": true } +``` + +| Status | Cause | +| ------ | ---------------------------------------------------------------- | +| `200` | Success | +| `400` | `id` is not an integer | +| `404` | `Collection not found` | +| `500` | The file could not be deleted, for example a permissions problem | + +:::warning Destructive +Permanently deletes the stored poster file from disk. **This cannot be undone**, and Maintainerr keeps no copy. + +It does **not** put the original artwork back on your media server. It only asks the server to refresh, and whether the original returns depends entirely on that server's own agents and caching. The poster Maintainerr pushed may well stay visible. +::: + +`cleared: true` does not mean a file was actually removed. It is returned unconditionally, so clearing a poster that was never uploaded also reports `cleared: true`. + +`refreshRequested: false` is not an error. It means the collection has no linked media server collection, no media server is reachable, or the refresh call failed. + +## Schedule + +### `PUT /api/collections/schedule/update` + +**Re-time the live collection handler cron job.** + +Request body: + +```json +{ "schedule": "0 */12 * * *" } +``` + +| Status | Cause | +| -------------------- | ------------------------------------------------------- | +| `200` with `code: 1` | Rescheduled | +| `200` with `code: 0` | The job is not registered, or restarting it threw | +| `400` | Validation failed, including an invalid cron expression | + +Note the envelope here has `code` and `message` but **no `status` key**, unlike most write routes on this page. + +:::caution This does not persist the schedule +The stored setting is untouched, so the change is lost on restart when the job is recreated from the saved value. + +The supported way to change the schedule is the [settings endpoint](./settings.md#post-apisettings), which persists it and then calls this route internally. +::: + +The expression must be exactly 5 fields. A 6-field expression with seconds is rejected. + +If a run is in flight the job is stopped and restarted underneath it. The running execution is unaffected. diff --git a/docs/api/logs.md b/docs/api/logs.md new file mode 100644 index 000000000..b12c5e98b --- /dev/null +++ b/docs/api/logs.md @@ -0,0 +1,179 @@ +--- +slug: /api/logs +title: Logs API +description: Live log stream, rotated log files, client error reporting, and log level settings. +--- + +Read the server log, download rotated log files, and change the log level and rotation settings. + +See [API conventions](../API.md#api-conventions) for the rules that apply across the API. + +## Reading logs + +### `GET /api/logs/stream` + +**Open a Server-Sent Events stream that replays the tail of the current log file and then pushes every new log line live.** + +Headers flush immediately, then the last 200 lines of the newest log file are replayed, then every new record is forwarded as it is written. A `: ping` comment is sent every 30 seconds to keep the connection alive. + +Each message is a `log` event: + +```text +event: log +data: {"message":"Collection handler finished","date":"2026-06-05T12:00:00.000Z","level":"INFO"} + +``` + +| Status | Cause | +| ------ | -------------------------------------------------------------------------------------------------------------------------- | +| `200` | Stream opened. Headers flush before any work, so later failures close or truncate the stream rather than change the status | + +Level filtering happens before the stream, so it honours the level actually in force. Remember that `LOG_LEVEL` in the environment beats the saved setting. + +A few things to expect. Live events uppercase the level, while replayed lines keep whatever case is in the file. Only files ending in `.log` are replayed, so compressed `.gz` archives never appear in the replay. Replayed lines that do not parse are silently dropped, and if the log directory cannot be read the stream simply starts empty rather than failing. + +There is no `Last-Event-ID` support, so a reconnect replays the same last 200 lines again. Stack traces arrive folded into `message` rather than as a separate field. Timestamps in the file are parsed as local time, so a server in a different time zone from the reader looks shifted. + +### `GET /api/logs/files` + +**List the rotated log files on disk with their sizes.** + +A directory listing only. No log content is opened or parsed. + +Response: + +```json +[ + { "name": "maintainerr-2026-06-05.log", "size": 20480 }, + { "name": "maintainerr-2026-06-04.log.gz", "size": 2048 } +] +``` + +`size` is in bytes, and a `.gz` entry reports its compressed size. Order is ascending, so oldest first. There is no server-side paging, limit or offset. + +| Status | Cause | +| ------ | -------------------------------------------------------------------------------------------------------------------- | +| `200` | Listing returned, possibly empty | +| `500` | The log directory could not be read, or a listed file could not be inspected because rotation removed it mid-request | + +Only Maintainerr's own daily files are listed. Anything else in the directory is invisible here, including the rotation bookkeeping file. + +:::note DATA_DIR is ignored here +In production this route reads `/opt/data/logs` directly and does not honour `DATA_DIR`. If you have moved your data directory, this list will not reflect it. +::: + +### `GET /api/logs/files/{file}` + +**Download one rotated log file as an attachment.** + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | --------------------------------------------------------------------------------------------------------- | +| `file` | string | Yes | A bare log filename such as `maintainerr-2026-06-05.log` or `maintainerr-2026-06-05.log.gz`. Never a path | + +Returns the raw file with `Content-Disposition: attachment`. A `.log` file is served as `text/plain` and a `.log.gz` as `application/gzip`. Archives are not decompressed for you. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `200` | File streamed | +| `400` | `Invalid file`: the name does not match the expected pattern, or the path is a symlink, not a regular file, or resolves outside the log directory | +| `404` | `File not found` | +| `500` | The file could not be inspected for a reason other than not existing, such as a permissions problem | + +Path traversal is blocked in four independent ways, so `..` segments, absolute paths and symlink bait all come back as `400`, not `404`. + +Errors here use the standard error envelope, not Maintainerr's `status` and `code` envelope. There is no `Content-Length` and no range support, so a download cannot be resumed, and a read failure after the stream started truncates an already-successful response rather than changing the status. A file listed by `GET /api/logs/files` can still `404` here if rotation removed it in between. + +## Log settings + +### `GET /api/logs/settings` + +**Return the saved log level, rotation size and backup count.** + +Response: + +```json +{ "level": "info", "max_size": 20, "max_files": 7 } +``` + +`level` is one of `debug`, `verbose`, `info`, `warn`, `error` or `fatal`. `max_size` is megabytes per file before rotation. `max_files` is the retention count. + +| Status | Cause | +| ------ | --------------------------- | +| `200` | Settings returned | +| `500` | The settings row is missing | + +:::caution This is the saved value, not the effective one +`LOG_LEVEL` in the environment overrides the saved level for the whole process lifetime. When it is set, this route still reports the stored value while the running logger uses the environment one. No endpoint reports the effective level. +::: + +### `POST /api/logs/settings` + +**Save the log level, rotation size and retention.** + +This is a full replace, not a partial update. All three fields are required. + +Request body: + +```json +{ "level": "info", "max_size": 20, "max_files": 7 } +``` + +| Field | Type | Required | Description | +| ----------- | ------ | -------- | ----------------------------------------------------------- | +| `level` | string | Yes | One of `debug`, `verbose`, `info`, `warn`, `error`, `fatal` | +| `max_size` | number | Yes | Megabytes per file before rotation. Minimum `0` | +| `max_files` | number | Yes | Retention. Minimum `1` | + +Neither number has to be a whole number, and `max_size` accepts a literal `0`. + +| Status | Cause | +| ------ | --------------------------------------------------------------------------------------------------- | +| `201` | Saved. **The response body is empty**, so re-read `GET /api/logs/settings` if you want confirmation | +| `400` | Validation failed | +| `500` | The save failed | + +Only the level change takes effect immediately. The rotation size and retention are written to the database but do not apply to the running process. They take effect after a restart. + +Two more things worth knowing. The running logger is updated **before** the database write, so a failed save leaves the process at the newly submitted level while `GET /api/logs/settings` keeps reporting the old one. And when `LOG_LEVEL` is set in the environment it silently wins: your value is still saved and still returned by the read route, but the running logger ignores it. + +## Client errors + +### `POST /api/logs/client-error` + +**Record a browser-side error from the Maintainerr UI into the server log.** + +The web UI calls this so front-end errors land in the same log as server errors. + +Request body, none of which is required or validated: + +```json +{ + "message": "Something failed", + "details": "TypeError: undefined is not a function", + "context": "Settings.Logs.stream", + "stack": "..." +} +``` + +| Field | Type | Description | +| --------- | ------ | -------------------------------------------------------------------------------- | +| `message` | string | Log message. Defaults to `Client error` | +| `details` | string | Extra detail | +| `context` | string | Where in the UI it happened. Defaults to `UI`. Also selects the level, see below | +| `stack` | string | Accepted but **discarded**. It is never read | + +Response: + +```json +{ "status": "OK", "code": 1, "message": "Logged" } +``` + +| Status | Cause | +| ------ | ----------------------------------------------------------------------- | +| `201` | Always, for any body including an empty one. There is no failure branch | + +The `context` value picks the level. `Settings.Logs.stream` is logged at debug so a flapping connection cannot flood the log. Everything else is logged as an error. `details` is attached as metadata but none of the log formats render it, so in practice it does not appear in any output. + +:::warning Anyone who can reach the port can write to your logs +The body is not validated, the route is not authenticated, and there is no rate limiting. Any caller can append arbitrary text to the log files and to every open log stream, and can choose the quieter debug level by sending the magic context value. This is one more reason not to expose Maintainerr publicly. See [Security and Authentication](../Security.md). +::: diff --git a/docs/api/media-server.md b/docs/api/media-server.md new file mode 100644 index 000000000..4c87820d2 --- /dev/null +++ b/docs/api/media-server.md @@ -0,0 +1,644 @@ +--- +slug: /api/media-server +title: Media Server API +description: Libraries, items, search, watch state, users, and collections on Plex, Jellyfin or Emby. +--- + +Direct access to the configured media server: Plex, Jellyfin or Emby. Maintainerr talks to all three through one adapter layer, so these routes have a single shape, but behaviour differs by backend more than anywhere else in the API. Those differences are called out per endpoint. + +See [API conventions](../API.md#api-conventions) for the rules that apply across the API. + +## Common responses + +Every route on this page is behind the media server setup check, which is the only guard in Maintainerr. It confirms that a media server is configured, **not** who is calling. These four outcomes apply to every endpoint here, so the per-endpoint tables below list only the route-specific codes. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------------------------------------- | +| `403` | No media server type is set, or the selected type's credentials are incomplete. Body is `Forbidden resource` | +| `503` | A media server switch is in progress, the credentials are not saved yet, or the adapter failed to start | +| `500` | The settings row could not be read, or no media server type is set in the database even though the in-memory check passed | +| `500` | Jellyfin only: the adapter had not started yet and starting it threw | + +:::note Absent and unreachable often look the same +Many read routes answer `200` with an empty body or an empty array for both "it is not there" and "the server could not be read". Where a route does that it is stated below. Where a route fails closed with a `500` instead, that is deliberate: a fabricated empty result would read as "the library is empty" and could let a rule remove media it never evaluated. +::: + +## Server status + +### `GET /api/media-server` + +**Report the configured media server's identity and version, or nothing when it is unreachable.** + +Response: + +```json +{ + "machineId": "abc123", + "version": "1.40.0", + "name": "My server", + "platform": "Linux", + "url": "http://jellyfin.example.com" +} +``` + +Plex fills only `machineId` and `version`. Jellyfin and Emby also fill `name`, `platform` and `url`. + +| Status | Cause | +| ------ | ----------------------------------------------------------------------------------- | +| `200` | Status returned, **or an empty body when the server is configured but unreachable** | + +An empty body means the server is down, not that no server is configured. That case is the `403`. + +Successful status reads are cached for 60 seconds on Jellyfin and Emby. A server that just came back is visible immediately, but one that just went down can still report fine for up to a minute. + +### `GET /api/media-server/type` + +**Return which media server backend is currently active.** + +Response: + +```json +{ "type": "plex" } +``` + +`type` is `plex`, `jellyfin` or `emby`. + +| Status | Cause | +| ------ | --------------------------- | +| `200` | The configured backend name | + +A `200` proves the credentials are configured and the adapter completed a handshake at least once. It is **not** a live reachability check. An adapter that started successfully stays marked as ready, so a server that has since gone down still answers here. Use `GET /api/media-server` for reachability. + +## Libraries + +### `GET /api/media-server/libraries` + +**List the movie and show libraries on the configured media server.** + +Response: + +```json +[ + { + "id": "1", + "title": "Movies", + "type": "movie", + "agent": "tv.plex.agents.movie" + } +] +``` + +`agent` is only set on Plex. Libraries of other kinds, such as music or photos, are dropped on all three backends. + +| Status | Cause | +| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `200` | The library list, possibly `[]` when the server is reachable and genuinely has no movie or show libraries | +| `503` | The list came back empty **and** the server could not be reached, reported as `Media server is configured but unreachable. Library list unavailable.` | + +Telling "empty" apart from "unreachable" is the whole point of this route, so do not read `[]` as a failure. + +The list is cached for 30 minutes on Jellyfin and Emby, so a newly added library can take that long to appear. + +### `GET /api/media-server/library/{id}/content` + +**Page through a library's items, sorted and annotated with Maintainerr exclusion and collection state.** + +| Parameter | Type | Required | Description | +| ----------- | -------------- | -------- | --------------------------------------------------------------------------------------------------- | +| `id` | path | Yes | Library id. A Plex section key, or a Jellyfin or Emby folder id | +| `page` | query, integer | No | 1-based page number, default `1`. Values below 1 are clamped to 1 | +| `limit` | query, integer | No | Items per page, default `50`. **No upper cap** | +| `type` | query | No | `movie`, `show`, `season` or `episode`. Not validated: an unrecognised value silently means `movie` | +| `sort` | query | No | `title`, `airDate`, `rating`, `watchCount`, `studio`, `manual` or `excluded` | +| `sortOrder` | query | No | `asc` or `desc`. Defaults to ascending | + +Response: + +```json +{ + "items": [ + { + "id": "12345", + "title": "An example title", + "guid": "plex://movie/abc", + "type": "movie", + "addedAt": "2026-01-01T00:00:00.000Z", + "providerIds": { "tmdb": ["550"] }, + "mediaSources": [{ "id": "1", "sizeBytes": 0 }], + "library": { "id": "1", "title": "Movies" }, + "maintainerrExclusionType": "global", + "maintainerrIsManual": true, + "maintainerrCollections": ["Example collection"] + } + ], + "totalSize": 1, + "offset": 0, + "limit": 50 +} +``` + +Items carry the full media item shape. On top of the media server's own fields, this route adds Maintainerr state: `maintainerrExclusionId`, `maintainerrExclusionType` which is `specific` or `global`, `maintainerrIsManual`, and `maintainerrCollections`, the titles of every collection the item belongs to. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------- | +| `200` | The page | +| `400` | `page` or `limit` is not an integer, or `sort` or `sortOrder` is outside its allowed values | +| `400` | `Studio sorting is not supported by the configured media server.` | +| `500` | The page read failed, or the Maintainerr state lookup failed | + +This route fails closed. A failed page read is a `500`, never an empty page. + +:::caution The status sorts walk the whole library +`sort=manual` and `sort=excluded` are Maintainerr state, not something the media server can sort by. Choosing either makes the server walk the **entire library** in batches, annotate every item, sort the whole set, and only then return your page. + +It stops after 15000 items. Past that the results are silently partial and `totalSize` reports how many items were gathered rather than the library total. Avoid these sorts on large libraries. +::: + +Note that `limit` has no cap and is passed straight through. A `limit` of `0` reaches the backend, where Plex and Emby honour it while Jellyfin substitutes its own default of 100. + +### `GET /api/media-server/library/{id}/content/search/{query}` + +**Search one library by title and return enriched results with parent metadata attached.** + +| Parameter | Type | Required | Description | +| --------- | ----- | -------- | --------------------------------------------------------------------------------------------------- | +| `id` | path | Yes | Library id to search inside | +| `query` | path | Yes | Search text. Taken raw from the path, so a query containing `/` will not route | +| `type` | query | No | `movie`, `show`, `season` or `episode`. Not validated: an unrecognised value silently means `movie` | + +Returns an array of media items with the same Maintainerr enrichment as the content route. Season and episode results also carry `parentItem`, the parent or grandparent's metadata. + +| Status | Cause | +| ------ | --------------------------------------------------------------------- | +| `200` | Matching items, **or `[]` when nothing matched or the search failed** | +| `500` | The Maintainerr state lookup failed | + +Unlike the paged content route, this one fails open. An empty array does not prove there were no matches. + +Search behaviour differs by backend. Plex matches on title as a prefix filter, while Jellyfin and Emby do a fuzzier search. Emby caps results at 100; Plex and Jellyfin apply no explicit cap here. + +### `GET /api/media-server/library/{id}/recent` + +**List recently added items from a library.** + +| Parameter | Type | Required | Description | +| --------- | -------------- | -------- | ------------------------------------------------------------------------------------ | +| `id` | path | Yes | Library id | +| `limit` | query, integer | No | Maximum items. There is no default from Maintainerr, so each backend applies its own | + +Returns raw media items with no Maintainerr enrichment. + +| Status | Cause | +| ------ | --------------------------------- | +| `200` | The items, **or `[]` on failure** | +| `400` | `limit` is not an integer | + +:::caution Behaviour differs sharply by backend +Plex interprets "recent" as **everything added in the last hour** and is never told your `limit`, so an idle server returns `[]` no matter what you pass. Jellyfin defaults to 50 items and Emby to 20. + +Emby needs a configured user id or it returns `[]` without contacting the server at all, and its results group episodes under their series, so asking for episodes gives you series rows. +::: + +### `GET /api/media-server/overview/bootstrap` + +**Fetch the library list plus the first library's first content page in one request.** + +A convenience route for the Overview page, so it can render in one round trip. + +| Parameter | Type | Required | Description | +| ----------- | -------------- | -------- | ----------------------------------------------------- | +| `limit` | query, integer | No | Page size for the embedded content page, default `50` | +| `sort` | query | No | Same values as the content route | +| `sortOrder` | query | No | `asc` or `desc` | + +Response: + +```json +{ + "libraries": [{ "id": "1", "title": "Movies", "type": "movie" }], + "selectedLibraryId": "1", + "content": { "items": [], "totalSize": 0, "offset": 0, "limit": 50 } +} +``` + +| Status | Cause | +| ------ | --------------------------------------------------------------------------------- | +| `200` | Libraries plus the first page | +| `400` | `limit` is not an integer, or `sort` or `sortOrder` is outside its allowed values | +| `500` | The page read failed, or the Maintainerr state lookup failed | +| `503` | The library list is unavailable because the server could not be reached | + +There is no way to choose which library is bootstrapped. It is always the first one the media server returned. The embedded page is always filtered to that library's own type, so a show library returns shows and never seasons or episodes. + +A `sort` of `manual` or `excluded` pays the full-library sweep described above, at page load. + +### `GET /api/media-server/search/{query}` + +**Search the whole media server and return enriched results with parent metadata attached.** + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ------------------------------------------------------------------------------ | +| `query` | path | Yes | Search text. Taken raw from the path, so a query containing `/` will not route | + +| Status | Cause | +| ------ | -------------------------------------------------------------- | +| `200` | Matches, **or `[]` when nothing matched or the search failed** | +| `500` | The Maintainerr state lookup failed | + +Result kinds differ by backend. Plex filters to movies and shows, so episodes never appear. Jellyfin and Emby return episodes as their own rows, each carrying a `parentItem`. Result caps are 50 on Jellyfin and 100 on Emby. + +## Items + +### `GET /api/media-server/meta/{id}` + +**Fetch full metadata for a single media item.** + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | --------------------------------------------------------- | +| `id` | path | Yes | Item id. A Plex rating key, or a Jellyfin or Emby item id | + +| Status | Cause | +| ------ | ---------------------------------------------------------------------------- | +| `200` | The item, **or an empty body for both "no such item" and "the read failed"** | + +There is no way to tell a missing item from a failed read here. + +:::caution Watch fields are per-user and cached +`viewCount`, `lastViewedAt` and `userRating` are scoped to the single Jellyfin or Emby user Maintainerr is configured with, and are cached for 5 minutes. Do not use them to drive watch or deletion decisions. Use `GET /api/media-server/meta/{id}/seen` for that. +::: + +### `GET /api/media-server/meta/{id}/children` + +**List an item's direct children, meaning a show's seasons or a season's episodes.** + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ---------------------------------- | +| `id` | path | Yes | Parent item id, a show or a season | + +| Status | Cause | +| ------ | ------------------------------------------------------------------- | +| `200` | The children, **or `[]` when the item has none or the read failed** | + +:::caution Unreliable for a show's seasons on Jellyfin and Emby +On Plex the hierarchy is unambiguous and this works for both shows and seasons. + +On Jellyfin and Emby it asks for items whose parent is the id you gave. A season's parent there is the **library folder**, not the show, so asking a series for its seasons does not reliably return them. Maintainerr uses a dedicated seasons lookup internally for that, and this route does not reach it. +::: + +Unaired placeholder episodes are not filtered out on this route. Emby caps the read at 500 rows. + +### `GET /api/media-server/meta/{id}/seen` + +**List completed watch records for one item, aggregated across users.** + +Response: + +```json +[ + { + "userId": "1", + "itemId": "12345", + "watchedAt": "2026-01-01T00:00:00.000Z", + "progress": 100 + } +] +``` + +| Status | Cause | +| ------ | ----------------------------------------------------- | +| `200` | The records, or `[]` when nobody has watched the item | +| `500` | The read failed | + +This route fails closed on purpose. Returning `[]` on failure would look like "never watched", which feeds rule checks and can get media deleted. + +That protection is not uniform. On Jellyfin the per-user reads are individually tolerant, so one user's read failing silently reads as "that user never watched it". Emby is stricter: a per-user permission or not-found response is skipped as a visibility miss, but any other per-user error aborts the whole request. + +What counts as "completed" is server-defined. Jellyfin honours its resume percentage setting, so a partly watched item can count. Emby only counts items explicitly marked played. Plex writes no history for an item marked watched without a play event, such as a manual mark or a Trakt scrobble, so those views never appear here. + +On Jellyfin and Emby this fans out per user, so cost grows with your user count. + +### `GET /api/media-server/meta/{id}/maintainerr-status` + +**Explain why an item is excluded from, or manually added to, Maintainerr collections.** + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | path | Yes | Item id | + +Response: + +```json +{ + "excludedFrom": [ + { "label": "Global" }, + { "label": "Example collection", "targetPath": "/collections/1/exclusions" } + ], + "manuallyAddedTo": [ + { "label": "Example collection (5d left)", "targetPath": "/collections/1" } + ] +} +``` + +A `Global` entry with no `targetPath` means a global exclusion. Other entries name a rule group's collection and link to it. The `(5d left)` suffix is the remaining countdown, computed at request time from the item's add date and the collection's delete-after days, so it changes between calls. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------ | +| `200` | The details, including two empty arrays when the item has no Maintainerr state | +| `500` | A database read failed | + +Exclusion matching is two-sided, which is how an episode inherits its show's exclusion. + +:::caution Fails soft in a way that changes the answer +If the media server cannot resolve the item, the lookup falls back to the item id alone. A show-level or season-level exclusion then silently disappears from the response and you get an empty `excludedFrom` rather than an error. +::: + +## Users + +### `GET /api/media-server/users` + +**List the media server's user accounts.** + +Response: + +```json +[{ "id": "1", "name": "example-user", "thumb": "/Users/1/Images/Primary" }] +``` + +On Jellyfin and Emby `thumb` is a relative path, not an absolute URL, and is only set when the user has an image. + +| Status | Cause | +| ------ | ----------------------------------------------- | +| `200` | The user list, **or `[]` when the read failed** | + +On Plex this is the server's own account list, meaning the owner plus managed and home users. It is not your Plex friends list. + +The list is cached for 30 minutes on Jellyfin and Emby, so a newly created user is invisible here for up to half an hour. + +### `GET /api/media-server/user/{id}` + +**Look up one media server user by id.** + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | -------------------------------------------------------------------------------------------- | +| `id` | path | Yes | User id. On Plex this must be the numeric account id. On Jellyfin and Emby it is the user id | + +| Status | Cause | +| ------ | ----------------------------------------------------------------------- | +| `200` | The user, **or an empty body for both "not found" and "lookup failed"** | + +There is no `404`, so this cannot be used to prove a user is gone. + +## Collections on the media server + +These routes act on collections as the media server stores them. They do **not** touch Maintainerr's own collection records, which live under [`/api/collections`](./collections.md). Using them on a collection Maintainerr manages will make the two disagree. + +Nothing in the Maintainerr web UI calls any of these routes. + +### `GET /api/media-server/collection/{id}` + +**Read one collection's metadata from the media server.** + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | --------------------------------- | +| `id` | path | Yes | Collection id on the media server | + +Response: + +```json +{ + "id": "999", + "title": "Example collection", + "type": "movie", + "summary": "", + "thumb": "/library/collections/999/thumb", + "childCount": 12, + "smart": false +} +``` + +`type` is only ever set on Plex. `smart` is always `false` on Jellyfin and Emby. `thumb` is a relative path, never an absolute URL. + +| Status | Cause | +| ------ | -------------------------------------------------------------------------- | +| `200` | The collection, **or an empty body when it is missing or the read failed** | + +On Emby the read has to be made as a user. If no administrator can be resolved, the route returns an empty body without contacting the server at all. + +### `GET /api/media-server/collection/{id}/children` + +**List the items a collection contains.** + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | --------------------------------- | +| `id` | path | Yes | Collection id on the media server | + +| Status | Cause | +| ------ | ---------------------------------------------------------------------------------- | +| `200` | The items, possibly `[]`, which means the server confirmed the collection is empty | +| `500` | Enumeration failed, including an unknown collection id on Plex | + +This fails closed everywhere, because callers treat `[]` as "confirmed empty" and would otherwise wipe membership. + +There is no paging: the whole collection is returned in one response. Items are **not** Maintainerr-enriched here, so the `maintainerr*` fields are absent. + +On Jellyfin and Emby nothing checks that the id is actually a collection, so pointing this at another container id enumerates that container instead of failing. + +### `GET /api/media-server/library/{id}/collections` + +**List the collections in a library.** + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | path | Yes | Library id | + +| Status | Cause | +| ------ | --------------------------------------------------------------------------------------------------------------- | +| `200` | The collections, possibly `[]`, which means the library genuinely holds none | +| `500` | Enumeration failed. An unknown library id usually lands here on Plex and as an empty `200` on Jellyfin and Emby | + +:::caution Always served from cache +This route always reads through the cache and cannot be told not to. A collection created seconds ago can be missing for up to 5 minutes on Plex, or 10 minutes on Jellyfin and Emby. Do not use it to decide whether a collection exists. +::: + +On Plex the listing includes smart collections. On Jellyfin and Emby collections are server-wide and are only associated with the library they were created under, so one holding items from several libraries appears under just that one. + +### `POST /api/media-server/collection` + +**Create a collection on the media server.** + +Request body: + +```json +{ + "libraryId": "1", + "title": "Example collection", + "type": "movie", + "summary": "Optional summary", + "sortTitle": "Optional sort title", + "initialItemId": "12345" +} +``` + +| Field | Type | Required | Description | +| --------------- | ------ | -------- | ------------------------------------------------------------------ | +| `libraryId` | string | Yes | Library to create it in | +| `title` | string | Yes | Collection title | +| `type` | string | Yes | `movie`, `show`, `season` or `episode` | +| `summary` | string | No | Description | +| `sortTitle` | string | No | Sort title | +| `initialItemId` | string | No | A single item to create the collection with. **Read only by Emby** | + +The body is not validated, so a missing `libraryId` or `title` reaches the media server as an empty value. + +| Status | Cause | +| ------ | ----------------------------------------------------------------- | +| `201` | Created. The body is the new collection | +| `500` | The media server rejected the create or the follow-up read failed | + +Nothing is written to Maintainerr's database, so a collection made this way is invisible to Maintainerr's own bookkeeping. Nothing deduplicates by title either, so repeated calls create duplicates. + +:::caution Backend differences are large here +On **Plex** the type is fixed at creation and Plex then rejects items of any other type. + +On **Jellyfin** `type`, `summary`, `sortTitle` and `initialItemId` are all ignored by the create call, yet the response echoes back the `summary` you sent. You can be told a summary was stored that Jellyfin never received. + +On **Emby** creating an empty collection under a library folder fails, which is exactly what `initialItemId` exists for. Omit it on Emby and the create is expected to fail. +::: + +### `PUT /api/media-server/collection` + +**Overwrite a collection's title, summary and sort title on the media server.** + +Request body: + +```json +{ + "libraryId": "1", + "collectionId": "999", + "title": "New title", + "summary": "New summary", + "sortTitle": "New sort title" +} +``` + +`libraryId` and `collectionId` are required. The rest are optional. + +| Status | Cause | +| ------ | ----------------------------------------------------------- | +| `200` | Updated. The body is the collection re-read from the server | +| `500` | The write failed, or the collection was not found | + +:::warning Destructive +Overwrites collection metadata on the media server. The previous title, summary and sort title are not kept anywhere, so **this cannot be undone** except by writing the old values back yourself. + +Maintainerr's own collection record is not updated, so renaming here makes Maintainerr's idea of the collection drift from the server's. Maintainerr rewrites its own values back over yours when its collection record is next saved or the collection is recreated. +::: + +:::caution A partial update is not safe on Jellyfin +Emby keeps the current value for any field you omit. **Jellyfin does not**: an omitted field is written as empty, wiping it. + +On Plex, sending `title` on its own also resets the sort title to match and unlocks it. +::: + +### `PUT /api/media-server/collection/visibility` + +**Set a Plex collection's home screen and recommended hub visibility.** + +Request body: + +```json +{ + "libraryId": "1", + "collectionId": "999", + "ownHome": true, + "sharedHome": true, + "recommended": true +} +``` + +`libraryId` and `collectionId` are required, plus at least one of the three flags. + +| Status | Cause | +| ------ | -------------------------------------------------------------------------------------- | +| `200` | Applied. The body is empty | +| `400` | `libraryId` or `collectionId` is missing, or all three flags were omitted | +| `500` | Plex rejected the write, or the backend is Jellyfin or Emby, which do not support this | + +:::warning Destructive +This is a full overwrite, not a partial update. All three flags are sent every time, and **any flag you omit is set to `false`**. + +Sending only `recommended: true` therefore silently clears `ownHome` and `sharedHome`. The validation only requires one flag to be present, which makes this easy to hit. Send all three flags every time. + +Maintainerr's own visibility columns are not updated, so calling this directly makes the UI disagree with the server until Maintainerr next writes its own values back. +::: + +This is Plex only. Jellyfin and Emby have no equivalent and report a `500` rather than a clearer error. + +### `PUT /api/media-server/collection/{collectionId}/item/{itemId}` + +**Add one item to a collection on the media server.** + +| Parameter | Type | Required | Description | +| -------------- | ---- | -------- | -------------------- | +| `collectionId` | path | Yes | Target collection id | +| `itemId` | path | Yes | Item id to add | + +| Status | Cause | +| ------ | -------------------------------------------------------------------------- | +| `200` | The change was attempted. The body is empty | +| `500` | Plex or Jellyfin rejected the write. **Emby never reports a failure here** | + +Membership is a set, so re-adding an existing member does nothing. + +On Emby, failures are logged and swallowed, so a `200` does not prove the item was added. + +On Jellyfin and Emby collections are server-wide, so an item from any library can be added. On Plex both must be in the same library section, and adding an item whose type does not match the collection is rejected. + +:::caution This changes what Maintainerr does next +On the next rule run for a linked collection, an item you added here that Maintainerr does not know about is **adopted as a manual member** rather than removed. It then becomes subject to that collection's delete-after countdown. +::: + +### `DELETE /api/media-server/collection/{collectionId}/item/{itemId}` + +**Remove one item from a collection on the media server.** + +| Parameter | Type | Required | Description | +| -------------- | ---- | -------- | ----------------- | +| `collectionId` | path | Yes | Collection id | +| `itemId` | path | Yes | Item id to remove | + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `200` | The change was attempted. The body is empty | +| `500` | Plex or Jellyfin rejected the removal. On Plex even "the item was not in the collection" is a `500`. **Emby never reports a failure here** | + +:::warning Destructive +Removes the item from the collection on the media server. **No media files are deleted** and nothing is removed from your library. Re-add it with the `PUT` route above. + +Maintainerr's own membership records are not updated by this route. On the next rule run for a linked collection the item is detected as missing and dropped from Maintainerr's membership as a manual removal. +::: + +Caches are not invalidated on Plex or Emby, so `GET /api/media-server/collection/{id}/children` can still list the removed item for a few minutes. + +### `DELETE /api/media-server/collection/{id}` + +**Delete a collection from the media server.** + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | --------------------------------- | +| `id` | path | Yes | Collection id on the media server | + +| Status | Cause | +| ------ | ----------------------------------------------------------------------------------------- | +| `200` | Deleted, or on Plex and Jellyfin the collection was already gone | +| `500` | The delete failed, or the collection still exists, or its existence could not be verified | + +:::warning Destructive +Permanently removes the collection on the media server. **This cannot be undone.** + +It removes the container only. The media inside it is not deleted and stays in your library. + +If a Maintainerr rule group points at this collection, Maintainerr's own record and its membership rows are left behind pointing at something that no longer exists. The stale link is only cleared later, once Maintainerr confirms the collection is missing. +::: + +Plex and Jellyfin treat "already gone" as success, but only when they can confirm it. An unreachable server is deliberately treated as "still there" so an outage never reads as a successful delete. Emby has no such check and reports a `500`. diff --git a/docs/api/metadata-and-storage.md b/docs/api/metadata-and-storage.md new file mode 100644 index 000000000..679b2eebf --- /dev/null +++ b/docs/api/metadata-and-storage.md @@ -0,0 +1,284 @@ +--- +slug: /api/metadata-and-storage +title: Metadata, Storage and Events API +description: Metadata provider lookups, storage metrics, library sizes, and the server-sent events stream. +--- + +Three unrelated groups that share a page: metadata provider lookups for artwork and descriptions, storage usage figures, and the live event stream the UI subscribes to. + +See [API conventions](../API.md#api-conventions) for the rules that apply across the API. + +## Metadata + +The three metadata routes resolve artwork and descriptions from the configured providers. They all behave the same way, so the shared rules are worth reading once. + +**How ids are supplied.** These routes do not declare named query parameters. Any non-empty query key ending in the literal string `Id` is treated as a provider id namespace, with the trailing `Id` stripped and the value converted to a number where it looks numeric. The useful ones are `tmdbId`, `tvdbId`, `imdbId` and `sportarrId`, but an unrecognised key such as `fooId` is also accepted and offered to the providers as an external id namespace. The suffix is case-sensitive: `tmdbID` and `tmdb` are both ignored. + +`itemId` is the one exception. It is excluded from the id bag and is instead read as a media-server item id. + +**Which provider answers.** Providers are consulted in the order set by your metadata provider preference, filtered to those actually available. TMDB is always available because a shared key ships with Maintainerr. TVDB only answers when a TVDB API key is saved. Sportarr is moved to the front for ids it owns. Missing provider ids are filled in first where possible, including bridging from an IMDB id, since there is no IMDB provider of its own. + +**Seasons and episodes.** Pass `itemId` alongside `type=show` to resolve a season or episode against its parent show. Without `itemId` a season request resolves as if it were the show. + +:::note +All three routes fail open and silently. An unreachable provider, a rejected id, or an unreadable media-server item all produce an empty `200` body, never a `404` or a `502`. An empty body is indistinguishable from "the provider had nothing". +::: + +The id check runs before the type check, so a request with an invalid `type` but no `*Id` parameter returns an empty `200` rather than the `400` you would expect. + +### `GET /api/metadata/image/{type}` + +**Resolve a poster image URL for an item from the configured metadata providers.** + +| Parameter | Type | Required | Description | +| ------------------------------------------ | ----- | -------- | --------------------------------------------------------------------- | +| `type` | path | Yes | `movie` or `show`. Anything else is a `400` | +| `itemId` | query | No | Media-server item id. Resolves a season or episode against its show | +| `tmdbId`, `tvdbId`, `imdbId`, `sportarrId` | query | No | Provider ids. At least one `*Id` key is needed for any work to happen | + +Response: + +```json +{ + "url": "https://image.tmdb.org/t/p/w300_and_h450_face/example.jpg", + "provider": "TMDB", + "id": 12345 +} +``` + +`provider` is `TMDB`, `TVDB` or `Sportarr`, and `id` is the provider id actually used. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------ | +| `200` | A result object, or an empty body when no ids were given or no provider had a poster | +| `400` | `type` is not `movie` or `show`, and at least one `*Id` was supplied | + +The poster size is fixed and cannot be overridden by a query parameter. Episodes get their season's poster, because no provider holds a portrait image per episode. The URL points at the provider's own image host, so the browser must be able to reach it. Maintainerr does not proxy the bytes. + +### `GET /api/metadata/backdrop/{type}` + +**Resolve a backdrop, or an episode still, from the configured metadata providers.** + +Takes the same parameters and returns the same shape as the poster route. + +| Parameter | Type | Required | Description | +| ------------------------------------------ | ----- | -------- | ---------------------------------------------------------------- | +| `type` | path | Yes | `movie` or `show`. Anything else is a `400` | +| `itemId` | query | No | Media-server item id. Needed for an episode to get its own still | +| `tmdbId`, `tvdbId`, `imdbId`, `sportarrId` | query | No | Provider ids. At least one `*Id` key is required | + +| Status | Cause | +| ------ | -------------------------------------------------------------------- | +| `200` | A result object, or an empty body when nothing resolved | +| `400` | `type` is not `movie` or `show`, and at least one `*Id` was supplied | + +With an episode reference TMDB returns the episode still. Seasons keep the show backdrop, because TMDB publishes no season backdrop. TVDB ignores the size hint. + +### `GET /api/metadata/overview/{type}` + +**Fetch a provider description for an item, for use where the media server has none.** + +The UI only calls this when the media server itself returned no summary. + +| Parameter | Type | Required | Description | +| ------------------------------------------ | ----- | -------- | ---------------------------------------------------------------------- | +| `type` | path | Yes | `movie` or `show`. Anything else is a `400` | +| `itemId` | query | No | Media-server item id. Enables season and episode specific descriptions | +| `tmdbId`, `tvdbId`, `imdbId`, `sportarrId` | query | No | Provider ids. At least one `*Id` key is required | + +Response: + +```json +{ "overview": "A description of the item." } +``` + +The body is empty when there is no description at all. + +| Status | Cause | +| ------ | -------------------------------------------------------------------- | +| `200` | An overview, or an empty body when nothing was found | +| `400` | `type` is not `movie` or `show`, and at least one `*Id` was supplied | + +This is the most expensive of the three routes. With `itemId` it can make two media-server reads before it even starts asking providers. TMDB descriptions are requested in English regardless of your media server's locale. TVDB has no description below show level, so on a TVDB-only setup season and episode requests always fall back to the series overview. + +## Storage metrics + +### `GET /api/storage-metrics` + +**Aggregate disk space, media server and collection storage figures into one snapshot.** + +Reads every Radarr and Sonarr instance's disk space and root folders, deduplicates mounts so a shared NAS mounted by two instances is only counted once, and combines that with collection totals and media-server library counts. This is what the Storage page renders. + +Response, abbreviated: + +```json +{ + "generatedAt": "2026-06-05T12:00:00.000Z", + "totals": { + "freeSpace": 0, + "totalSpace": 0, + "usedSpace": 0, + "mountCount": 0, + "accurateMountCount": 0, + "accurateTotalSpace": true + }, + "mounts": [ + { + "instanceId": 1, + "instanceType": "radarr", + "instanceName": "Radarr", + "path": "/movies", + "label": "/", + "freeSpace": 0, + "totalSpace": 0, + "hasAccurateTotalSpace": true + } + ], + "instances": [ + { + "id": 1, + "name": "Radarr", + "type": "radarr", + "ok": true, + "error": null, + "mountCount": 1 + } + ], + "mediaServer": { + "configured": true, + "serverType": "plex", + "serverName": "My server", + "reachable": true, + "error": null, + "libraries": [ + { + "id": "1", + "title": "Movies", + "type": "movie", + "itemCount": 0, + "sizeBytes": null + } + ], + "totalItemCount": 0 + }, + "collectionSummary": { + "reclaimableCount": 0, + "activeSizeBytes": 0, + "reclaimableSizedCount": 0, + "inactiveCount": 0, + "totalCollectionCount": 0, + "movieSizeBytes": 0, + "showSizeBytes": 0, + "seasonSizeBytes": 0, + "episodeSizeBytes": 0, + "reclaimableMovieCount": 0, + "reclaimableShowCount": 0, + "reclaimableSeasonCount": 0, + "reclaimableEpisodeCount": 0, + "reclaimableUsingFallback": false + }, + "topCollections": [ + { + "id": 1, + "title": "Example collection", + "type": "movie", + "mediaCount": 0, + "totalSizeBytes": 0, + "isActive": true + } + ], + "cleanupTotals": { + "itemsHandled": 0, + "moviesHandled": 0, + "showsHandled": 0, + "seasonsHandled": 0, + "episodesHandled": 0, + "bytesHandled": 0, + "movieBytesHandled": 0, + "showBytesHandled": 0, + "seasonBytesHandled": 0, + "episodeBytesHandled": 0 + } +} +``` + +| Status | Cause | +| ------ | ---------------------------------------------------------------------------------------------------- | +| `200` | Normal. Per-instance and media-server failures are reported inside the payload, not as an HTTP error | +| `500` | One of the underlying database reads threw | + +Each source fails open independently. An `*arr` instance with no URL or API key comes back with `ok: false` and an error of `Instance is not fully configured`. One whose disk-space read fails reports its error and contributes no mounts. The media-server block distinguishes three states: `configured: false` when no server type is set, `reachable: false` with `error: null` when a type is set but the adapter is not, and `reachable: false` with a message when a library read failed. None of these change the status code. + +Two figures need care. `totals` only sums capacity for mounts flagged `hasAccurateTotalSpace`, because Sonarr omits network drives from its disk-space report and those arrive without a capacity. And `collectionSummary.activeSizeBytes` only deduplicates items shared across collections while every reclaimable collection has per-item sizes. Otherwise it falls back to cached per-collection totals, sets `reclaimableUsingFallback: true`, and counts shared items more than once. + +`mediaServer.libraries[].sizeBytes` comes from the cheap path and is `null` for Plex and Emby by design. Only Jellyfin 10.11 and newer with an admin user answers, and its figure is device-level used space summed over the library's folders, not a media-file total. For accurate numbers use `/api/storage-metrics/library-sizes`. + +Disk-space reads are cached per instance for an hour, so repeated calls within that window return the same `*arr` figures. Only the database reads are always fresh. + +### `GET /api/storage-metrics/library-sizes` + +**Compute accurate per-library byte totals by iterating every item on the media server.** + +Walks every movie and episode on the media server and sums their file sizes. + +Response: + +```json +{ + "generatedAt": "2026-06-05T12:00:00.000Z", + "sizeBytesByLibrary": { "1": 0, "2": 0 } +} +``` + +Keys are media-server library ids. Plex and Jellyfin set an entry for every library, possibly `0`. Emby only sets libraries whose total came out above zero, so a failed or empty Emby library is simply absent. + +| Status | Cause | +| ------ | ----------------------------------------------------------------------------------------------------------------------------- | +| `200` | The computed, or cached, map | +| `500` | The computation threw, for example when the library list could not be read | +| `503` | No media server configured, the adapter is not set up, a media-server switch is in progress, or credentials are not saved yet | + +:::warning Expensive +This iterates every item on the media server and on Plex adds a per-item request for anything whose paged record lacks a size. On a large library it generates a very large number of media-server calls. Call it on demand, not on a schedule. +::: + +Results are cached for 15 minutes. There is no invalidation when the library changes, so a fresh figure needs a call after the cache expires. Concurrent callers share one in-flight computation, and a failed computation is not cached. + +Partial failures are mostly silent. A failed page read returns the running total, a failed Plex show-library read returns `0` after a warning, and Emby omits a failed library from the map entirely. A library can therefore be under-reported, reported as `0`, or missing without any error reaching you. + +This is independent of the cheap `sizeBytes` in `GET /api/storage-metrics`. Calling it does not update that payload. + +## Events + +### `GET /api/events/stream` + +**Open a Server-Sent Events stream of rule-handler and collection-handler progress events.** + +A long-lived `text/event-stream` connection. Headers flush immediately and the stream stays open until the client disconnects or the app shuts down. A `: ping` comment is written every 30 seconds to keep it alive. + +Each message looks like this: + +```text +event: collection_handler.progressed +id: 42 +data: {"type":"collection_handler.progressed","time":"2026-06-05T12:00:00.000Z","totalCollections":3} + +``` + +Seven event types are emitted: + +| Event | Payload | +| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `rule_handler.started`, `rule_handler.finished` | `type`, `time`, `message` | +| `rule_handler.progressed` | `type`, `time`, `ruleGroupName`, `totalEvaluations`, `processedEvaluations` | +| `collection_handler.started`, `collection_handler.finished` | `type`, `time`, `message` | +| `collection_handler.progressed` | `type`, `time`, `totalCollections`, `totalMediaToHandle`, `processedMedias`, `processedCollections`, and `processingCollection` with `name`, `processedMedias` and `totalMedias` | +| `rule_handler_queue.status_updated` | `type`, `time`, and `data` with `processingQueue`, `executingRuleGroupId`, `pendingRuleGroupIds` and `queue` | + +| Status | Cause | +| ------ | ----------------------------------------------------------------------------- | +| `200` | Headers flush as soon as the handler runs. There is no other reachable status | + +Send a `Last-Event-ID` header to ask for a replay of buffered events. Replay is best effort: at most 100 events are buffered, they expire after 5 minutes, and ids restart from 1 on every process start. A client reconnecting after a restart normally holds an id higher than any new id, so its backlog is silently skipped. When no replay happens the server may instead resend the most recent event if it is under 5 seconds old, which can duplicate an event the client already has. + +Only these seven of Maintainerr's event types reach the stream. Collection media added, removed and handled events, the failure events, and the notification, settings and overlay events are not on it. diff --git a/docs/api/notifications.md b/docs/api/notifications.md new file mode 100644 index 000000000..070353473 --- /dev/null +++ b/docs/api/notifications.md @@ -0,0 +1,296 @@ +--- +slug: /api/notifications +title: Notifications API +description: Notification agents, configurations, rule group links, and test sends. +--- + +Create and manage notification agent configurations, attach them to rule groups, and send test messages. See the [Notifications](../Notifications.md) page for what the feature does. + +See [API conventions](../API.md#api-conventions) for the rules that apply across the API. + +:::caution Two different response envelopes on one page +`POST /api/notifications/configuration/add` answers with the usual `status`, `code` and `message` envelope. The connect, disconnect and delete routes instead answer with `code` and **`result`**. Read the field names carefully. + +Either way, failure is reported in the body and not in the status code. +::: + +## Reference data + +### `GET /api/notifications/agents` + +**List every supported notification agent and the option fields it accepts.** + +A fixed list built in code. Nothing is read from the database and nothing is contacted. The UI renders one input per entry when you add or edit an agent. + +Response: + +```json +[ + { + "name": "discord", + "friendlyName": "Discord", + "options": [ + { + "field": "webhookUrl", + "type": "text", + "required": true, + "extraInfo": "" + }, + { + "field": "botUsername", + "type": "text", + "required": false, + "extraInfo": "" + }, + { + "field": "botAvatarUrl", + "type": "text", + "required": false, + "extraInfo": "" + } + ] + } +] +``` + +Agents are returned in a fixed order: `email`, `discord`, `lunasea`, `slack`, `telegram`, `pushbullet`, `pushover`, `webhook`, `gotify`, `ntfy`. The `name` values are exactly the `agent` keys the add and test routes accept. `type` is one of `text`, `password`, `number`, `checkbox` or `json`. + +| Status | Cause | +| ------ | ----------------------------- | +| `200` | Always. The route cannot fail | + +:::note This is form metadata, not validation +Nothing on the server checks a submitted `options` object against this spec, and the `required` flags do not always match what an agent actually needs. Telegram is listed as requiring `chatId` but only checks the bot token before sending, and Pushbullet checks nothing at all. Treat the flags as UI hints. + +For email, `secure`, `ignoreTls` and `requireTls` describe mutually exclusive TLS modes and nothing stops you setting more than one. +::: + +### `GET /api/notifications/types` + +**List the subscribable notification event types with their numeric values.** + +Response: + +```json +[ + { "title": "Media Added To Collection", "id": 2 }, + { "title": "Media Removed From Collection", "id": 4 }, + { "title": "Media About To Be Handled", "id": 8 }, + { "title": "Media Handled", "id": 16 }, + { "title": "Rule Handling Failed", "id": 32 }, + { "title": "Collection Handling Failed", "id": 64 }, + { "title": "Overlay Applied", "id": 256 }, + { "title": "Overlay Reverted", "id": 512 }, + { "title": "Update Available", "id": 1024 } +] +``` + +| Status | Cause | +| ------ | ----------------------------- | +| `200` | Always. The route cannot fail | + +The ids are powers of two, but they are **not** used as a bitmask. Store them as a plain array of numbers in a configuration's `types` field, and the server matches by array membership. + +The test notification type, `128`, is deliberately missing from this list. It is a real value, and the test route appends it to whatever `types` you send. That is exactly what stops a real event from being delivered as a test. + +## Configurations + +### `GET /api/notifications/configurations` + +**Return every stored notification agent configuration.** + +Response: + +```json +[ + { + "id": 1, + "name": "My Discord", + "agent": "discord", + "enabled": true, + "types": [2, 4], + "options": { + "agent": "discord", + "webhookUrl": "https://discord.com/api/webhooks/..." + }, + "aboutScale": 3 + } +] +``` + +| Status | Cause | +| ------------------------ | ---------------------------- | +| `200` | JSON array of configurations | +| `200` with an empty body | The read failed | + +An empty body is not the same as `[]`. `[]` means no agents are configured, an empty body means the read threw. Check the body type to tell them apart. + +Rule group links are not included on this route. + +:::danger Secrets are returned in cleartext +There is no masking on this route. `options` carries webhook URLs, auth headers, bot tokens, access tokens, user tokens, SMTP passwords and PGP keys exactly as stored. + +This is unlike `GET /api/settings`, which masks its secrets. Since the API has no authentication, anyone who can reach the port can read every notification credential you have saved. See [Security and Authentication](../Security.md). +::: + +A row whose `agent` value this build does not recognise is still returned here even though it produces no working agent. It is skipped with a warning when agents are registered. + +### `POST /api/notifications/configuration/add` + +**Create a notification agent configuration, or update an existing one.** + +This is both the create and the update path. There is no `PUT` or `PATCH`. Omit `id` to create; supply it to update. + +Request body: + +```json +{ + "id": 1, + "agent": "discord", + "name": "My Discord", + "enabled": true, + "types": [2, 4], + "aboutScale": 3, + "options": { + "agent": "discord", + "webhookUrl": "https://discord.com/api/webhooks/..." + } +} +``` + +| Field | Type | Required | Description | +| ------------ | -------- | -------- | --------------------------------------------------------------------------------------------------------------- | +| `id` | number | No | Omit to create. Supply to update that row | +| `agent` | string | Yes | An agent key from `GET /api/notifications/agents` | +| `name` | string | Yes | Display name | +| `enabled` | boolean | Yes | Whether the agent may send | +| `types` | number[] | Yes | Event type ids from `GET /api/notifications/types` | +| `aboutScale` | number | Yes | How many days before an item's scheduled handling date the "about to be handled" warning fires. Defaults to `3` | +| `options` | object | Yes | The agent-specific option block | + +Response: + +```json +{ "status": "OK", "code": 1, "message": "Success" } +``` + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------ | +| `201` | Always, including every failure. Read `status` and `code` in the body, not the status line | + +Changes take effect for live delivery immediately. No restart is needed, and nothing is sent to the destination. + +:::caution Updates that silently do nothing +The create-or-update decision is made purely on whether `id` is **present**, not on whether it is useful. Sending `"id": null` or `"id": 0` takes the update path, matches no row, writes nothing, and still answers `code: 1` and `status: "OK"`. An `id` that matches no existing row behaves the same way. + +There is no `404` here, and no way to tell a real update from a no-op. +::: + +`agent`, `types` and `options` are not validated. An unknown agent key, or an options block missing fields the agent needs, is stored happily. The mismatch only shows up later as a skipped agent or a failure at send time. The real gate is that `name`, `agent` and `options` cannot be null, so omitting one of those fails the write and comes back as `status: "NOK"`. + +Editing an agent does not touch its rule group links. + +### `DELETE /api/notifications/configuration/{id}` + +**Permanently delete a notification agent configuration and all of its rule group links.** + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | --------------------------------- | +| `id` | number | Yes | Id of the configuration to delete | + +Response: + +```json +{ "code": 1, "result": "success" } +``` + +| Status | Cause | +| ------ | ------------------------------------------------------------------------ | +| `200` | Always, success or failure. An id matching no row also returns `code: 1` | + +:::warning Destructive +Deletes the configuration row and, by a database cascade, **every rule group link that used it**. Those rule groups silently stop notifying, with no warning and no list of what was affected. + +**This cannot be undone.** There is no soft delete and no server-side confirmation step, and the stored credentials go with it. The only confirmation is the dialog in the web UI. + +If you only want to stop delivery, set `enabled: false` through `POST /api/notifications/configuration/add` instead. +::: + +The response cannot tell a real deletion from a no-op, because the number of affected rows is never checked. + +## Rule group links + +Both routes below work, but nothing in Maintainerr calls them. The web UI attaches agents to a rule group by sending the whole `notifications` array to the [rules endpoints](./rules.md) instead, which is the path to prefer. + +### `POST /api/notifications/configuration/connect` + +**Attach an existing notification configuration to an existing rule group.** + +Request body: + +```json +{ "rulegroupId": 1, "notificationId": 2 } +``` + +Both ids are checked for truthiness, so `0` counts as missing. + +Response: + +```json +{ "code": 1, "result": "success" } +``` + +| Status | Cause | +| ------ | ----------------------------------------------------------------------------------------------------------------- | +| `201` | Always, including failure. A missing rule group or notification is `code: 0` with `result: "failed"`, not a `404` | + +A missing record and a falsy id produce the same `failed` result, so you cannot tell them apart. + +### `POST /api/notifications/configuration/disconnect` + +**Detach a notification configuration from a rule group.** + +Removes the link only. The configuration itself is left completely intact, so use `DELETE /api/notifications/configuration/{id}` if you want to remove the agent. + +Request body: + +```json +{ "rulegroupId": 1, "notificationId": 2 } +``` + +| Status | Cause | +| ------ | ----------------------------------------------------------------------------------------------------------------- | +| `201` | Always, including failure. A missing rule group or notification is `code: 0` with `result: "failed"`, not a `404` | + +Disconnecting a link that was never there returns `code: 1` and `result: "success"`, because both records exist and removing nothing succeeds. + +## Testing + +### `POST /api/notifications/test` + +**Fire a real test notification through an agent configuration supplied in the request body.** + +The agent is built from the body rather than looked up, so you can test values before saving them. The test type is appended automatically, so you do not need to include it in `types`. + +Takes the same body as `POST /api/notifications/configuration/add`. Only `agent`, `enabled` and `options` decide whether anything is delivered. `id`, `name` and `aboutScale` are ignored. + +The response is a bare JSON string, not an object. It is `Success`, or `Failure: ` followed by a reason, or `Agent is not allowed to send this message.` + +| Status | Cause | +| ------ | ---------------------------------------------------------------------------- | +| `201` | Every delivery outcome, success or failure. The result is in the string body | +| `500` | Malformed body, specifically a missing `types` or a missing `options` | + +Nothing is written to the database and the live agent list is untouched. Because no media items are involved, a test works even with no media server configured. + +:::warning This sends a real message to a destination you name in the request +The credentials and the target URL both come from the request body, so this route will make an outbound request to whatever address the caller supplies. The only check is that webhook style agents use an `http` or `https` scheme. There is no host or private network filtering, and Gotify's URL is not checked at all. + +On an unauthenticated instance that is a way to make your server issue requests on someone else's behalf. Treat this as an operator-only endpoint and see [Security and Authentication](../Security.md). +::: + +Three results that look like success but are not: + +- `enabled` normally has to be `true` or nothing is sent, and you get `Agent is not allowed to send this message.` rather than a clear error. **Pushbullet is the exception**: a disabled or token-less Pushbullet configuration answers `Success` having delivered nothing. +- An unrecognised `agent` key returns a flat `Success` without sending anything. +- Any agent whose stored `types` do not match returns `Success` without sending. This cannot happen on this route, since the test type is always appended, but it is worth knowing when reading agent behaviour. diff --git a/docs/api/overlays.md b/docs/api/overlays.md new file mode 100644 index 000000000..2d8414dce --- /dev/null +++ b/docs/api/overlays.md @@ -0,0 +1,711 @@ +--- +slug: /api/overlays +title: Overlays API +description: Overlay settings, processing runs, templates, fonts, images and previews. +--- + +Everything behind the overlay feature: the global switch and schedule, the runs that burn artwork onto your media server, the template editor's data, and the font and image assets templates draw with. See the [Overlays](../Overlays.md) page for what the feature does. + +See [API conventions](../API.md#api-conventions) for the rules that apply across the API. + +:::note Every route here needs a configured media server +The whole controller is behind the media server setup check, so **all** of these answer `403 Forbidden resource` when no media server type is set, or when the selected type's credentials are incomplete. + +That includes the routes that only touch local disk, such as listing fonts, and the purely diagnostic status route. On a fresh install the overlay editor cannot load anything until setup is done. The check reads stored settings only and never contacts the server, so a configured but unreachable server still passes. +::: + +To keep the tables short, that `403` is listed once here rather than repeated on all 28 endpoints below. + +## How reverting works + +Before Maintainerr overlays an item for the first time it saves the untouched artwork to a backup directory. A revert or a reset re-uploads that backup. + +:::caution A revert restores the backup, not the original source +It puts back exactly the bytes captured before the first overlay was applied. That means artwork you changed by hand **after** an overlay was applied is overwritten by the older backup. + +If the backup file is lost, the item stays overlaid forever. Maintainerr clears its tracking and reports nothing to restore. +::: + +## Settings + +### `GET /api/overlays/settings` + +**Return the overlay settings.** + +| Status | Cause | +| ------ | -------------------------------------------------------- | +| `200` | The settings | +| `500` | The read, or the first-run write described below, failed | + +The response carries `enabled`, `cronSchedule`, and six style blocks: `posterOverlayText`, `posterOverlayStyle`, `posterFrame`, `titleCardOverlayText`, `titleCardOverlayStyle` and `titleCardFrame`. + +:::note The six style blocks are legacy +They are still stored and returned, but nothing reads them any more. Template-based rendering replaced them. Only `enabled` and `cronSchedule` still have an effect. +::: + +This `GET` writes on a fresh install: if the settings row does not exist yet it is created from defaults before the response is built. + +### `PUT /api/overlays/settings` + +**Update overlay settings and reschedule the overlay cron job.** + +Every field is optional, and omitted fields keep their stored value. + +```json +{ "enabled": true, "cronSchedule": "0 4 * * *" } +``` + +| Status | Cause | +| ------ | ---------------------------------------------------------- | +| `200` | Written. The body is the full re-read settings object | +| `400` | Validation failed | +| `500` | **An empty JSON object `{}`**, or a database write failure | + +Sending `enabled` or `cronSchedule` also retimes the overlay job. Anything else parks it so it never runs. + +:::caution Two traps +An empty body `{}` is a `500`, not a no-op, because there is nothing to update. + +`cronSchedule` is stored **without any syntax validation**, and an invalid expression is worse than it looks: the job is stopped before the new schedule is parsed, so a bad expression stops the job and never restarts it. The request still returns `200`, and scheduled overlay runs stay stopped until you save a valid expression or restart the server. +::: + +The style blocks are top-level partial only. If you send one, send it complete. + +Setting `enabled: false` does not remove any overlay already applied. Artwork stays overlaid until a revert or a reset. + +## Processing + +### `GET /api/overlays/status` + +**Report the overlay processor's current state and last run summary.** + +Response: + +```json +{ + "status": "idle", + "lastRun": "2026-06-05T04:00:00.000Z", + "lastResult": { "processed": 12, "reverted": 0, "skipped": 3, "errors": 0 } +} +``` + +`status` is `idle`, `running` or `error`. + +| Status | Cause | +| ------ | ----------------------------------- | +| `200` | The status. The handler cannot fail | + +This is the completion signal for the two fire-and-forget routes. Poll it until `status` is no longer `running`. + +:::caution Three things this does not tell you +A run that returned early, because overlays are disabled or no provider is available, still stamps a fresh `lastRun` and an all-zero `lastResult`. Nothing distinguishes "skipped" from "clean pass". + +A single-collection run or a revert flips `status` to `running` but **never updates** `lastRun` or `lastResult`, so a poller can see `running` followed by a stale summary. + +`status` returns to `idle` after a failed reset too. Only `lastResult.errors` records that items could not be restored. +::: + +All of this state is in memory and resets on restart. + +### `POST /api/overlays/process` + +**Start a full overlay run across every overlay-enabled collection.** + +Request body: + +```json +{ "force": true } +``` + +`force` is optional and defaults to `false`. Setting it re-renders every item even when nothing about it changed, which is what you want after editing a template or its styling. + +| Status | Cause | +| ------ | ------------------------------------------ | +| `202` | The run was **started**. The body is empty | +| `400` | `force` is not a boolean | +| `409` | `An overlay run is already in progress` | + +:::warning Destructive to artwork +For every targeted item this **uploads a re-rendered image over the item's poster or still** on your media server, and re-uploads the saved original for items that dropped out of coverage. + +It is reversible in principle, because every original is backed up first, but only as good as that backup. See [How reverting works](#how-reverting-works). No media files are touched. + +On Plex nothing is deleted when a new poster is uploaded, so items accumulate uploaded posters over time. +::: + +Only collections that actually delete on a schedule are targeted, meaning those with `deleteAfterDays` set and a delete-style action. A collection whose action only unmonitors, or does nothing, has its previous overlays reverted instead. + +The run outlives the request, so a `202` only means it was accepted. Poll `GET /api/overlays/status`. + +:::caution Two silent failure modes +Rendering needs the native image libraries. If they are unavailable, every item counts as an error, nothing is uploaded, and the backup taken on that pass is deleted again, so a later reset cannot restore it. + +On Plex, selecting the newly uploaded poster can fail without being reported. The item is then counted as processed and a tracking row is written, while the visible poster never changed. +::: + +There is a second interlock beyond the `409`: a run that cannot take the overlay lock is silently skipped **after** the `202` was already sent. + +### `POST /api/overlays/process/{collectionId}` + +**Run overlay processing for one collection and return its summary.** + +Unlike the global route, this holds the request open for the whole run and returns the result. + +| Parameter | Type | Required | Description | +| -------------- | ------- | -------- | ------------------------- | +| `collectionId` | integer | Yes | Maintainerr collection id | + +Response: + +```json +{ "processed": 12, "reverted": 0, "skipped": 3, "errors": 0 } +``` + +`reverted` is always `0` here. Only the global run performs the revert sweep. + +| Status | Cause | +| ------ | ---------------------------------------------------------------------- | +| `201` | Run completed. The body is the summary | +| `400` | `collectionId` is not an integer | +| `404` | `Collection not found` | +| `409` | An overlay run is already in progress | +| `500` | The run threw. Per-item failures do not throw, they increment `errors` | + +:::warning Destructive to artwork +The same artwork overwrites as the global run, scoped to one collection. See [How reverting works](#how-reverting-works). +::: + +There is no `force` option here, so items whose stored state still matches are skipped. + +:::caution This ignores the collection's own overlay switch +It does **not** check the collection's `overlayEnabled` flag. A collection with overlays turned off still gets them applied, as long as it deletes on a schedule and the global switch is on. + +If the overlay lock is held it answers `201` with an all-zero summary rather than a `409`. +::: + +Neither `lastRun` nor `lastResult` is updated by this route, so the status route keeps showing the previous global run. + +### `POST /api/overlays/revert/{collectionId}` + +**Restore the original artwork for every item this collection overlaid.** + +| Parameter | Type | Required | Description | +| -------------- | ------- | -------- | ------------------------------------------------------------------------------------------------ | +| `collectionId` | integer | Yes | Maintainerr collection id. It does not have to exist: a missing one simply has nothing to revert | + +| Status | Cause | +| ------ | ----------------------------------------------------------------------------------------------------- | +| `201` | Revert performed, **or queued** behind a running overlay operation. The body is `{ "success": true }` | +| `400` | `collectionId` is not an integer | +| `500` | Reading the tracking rows or the collection row threw | + +:::warning Destructive to current artwork +Re-uploads each saved backup **over whatever the item currently shows**, then deletes the backup and stops tracking the item. Artwork you changed by hand after the overlay was applied is overwritten. See [How reverting works](#how-reverting-works). +::: + +This never returns `409`. If another overlay operation is running, the work is queued rather than skipped, because otherwise an overlay could be left with nothing able to remove it. + +:::caution `success: true` is not proof anything was restored +The response says success even when every item failed, and it carries no count and no indication of whether the work ran or was queued. + +Each item ends in one of four states: restored, no backup so the item stays overlaid, gone from the server, or failed. A failed item deliberately keeps its backup and tracking row so a later run can retry. On Plex a swallowed failure can report a restore while the visible poster is still the overlaid one. +::: + +### `DELETE /api/overlays/reset` + +**Restore original artwork for every overlaid item on the server.** + +| Status | Cause | +| ------ | -------------------------------------------- | +| `202` | The reset was **started**. The body is empty | +| `409` | An overlay run is already in progress | + +:::warning Bulk destructive to current artwork +Re-uploads **every** saved backup over whatever those items currently show, across all collections. It also picks up orphaned backups that no tracking row claims, since those are the only remaining record that artwork may have been changed. + +Backups are deleted as they are restored, and the tracking rows go with them. Artwork you changed by hand after an overlay was applied is overwritten by the older backup. See [How reverting works](#how-reverting-works). +::: + +This deliberately still works while the overlay feature is globally disabled. It is the escape hatch for turning overlays off and getting your artwork back. + +Items with no backup on disk are left overlaid. Items that fail to upload keep both their backup and their tracking row and are counted as errors. + +Poll `GET /api/overlays/status` for the summary. On this path only `reverted` and `errors` move. + +:::caution A reset with no provider looks clean +If there is no overlay provider the reset returns early but still stamps a fresh `lastRun` and an all-zero summary, so the status route shows what looks like a successful reset. +::: + +## Templates + +### `GET /api/overlays/templates` + +**List every overlay template, presets and user templates alike.** + +There is no filtering and no paging. Split poster from title card templates using each item's `mode`. + +Each template carries `id`, `name`, `description`, `mode`, `canvasWidth`, `canvasHeight`, `elements`, `isDefault`, `isPreset`, `createdAt` and `updatedAt`. + +| Status | Cause | +| ------ | ------------------------ | +| `200` | The templates | +| `500` | The database read failed | + +Four presets are seeded on first start: Classic Pill, Countdown Bar, Corner Badge and Title Card Pill. They cannot be updated or deleted, only duplicated, exported or made default. Seeding only happens when the table is empty and only at startup, so this never re-seeds. + +Templates carry no media server binding and are shared across whichever server is configured. + +Two templates of the same mode can briefly both be marked default. See the update route for how. + +### `GET /api/overlays/templates/{id}` + +**Fetch one overlay template by id.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ----------- | +| `id` | integer | Yes | Template id | + +| Status | Cause | +| ------ | ------------------------ | +| `200` | The template | +| `400` | `id` is not an integer | +| `404` | `Template not found` | +| `500` | The database read failed | + +`elements` comes back exactly as stored and is not re-validated, so a row edited directly in the database can return element shapes the write routes would have rejected. + +### `POST /api/overlays/templates` + +**Create a user template from a full template definition.** + +Request body: + +```json +{ + "name": "My template", + "description": "", + "mode": "poster", + "canvasWidth": 1000, + "canvasHeight": 1500, + "elements": [], + "isDefault": false +} +``` + +| Field | Type | Required | Description | +| ----------------------------- | ------- | -------- | --------------------------------------- | +| `name` | string | Yes | 1 to 100 characters | +| `mode` | string | Yes | `poster` or `titlecard` | +| `canvasWidth`, `canvasHeight` | integer | Yes | Positive | +| `elements` | array | Yes | May be empty | +| `description` | string | No | Up to 500 characters, defaults to empty | +| `isDefault` | boolean | No | Defaults to `false` | + +Each element is one of four types, all sharing `id`, `x`, `y`, `width`, `height`, `layerOrder`, and the optional `rotation`, `opacity` and `visible`. + +- **`text`** adds `text`, `fontFamily`, `fontPath`, `fontSize`, `fontColor`, and optional `fontWeight`, `textAlign`, `verticalAlign`, `backgroundColor`, `backgroundRadius`, `backgroundPadding`, `shadow` and `uppercase`. +- **`variable`** has the same typography but replaces `text` with `segments`, a list of literal text parts and variable parts drawn from `date`, `days` and `daysText`. It also takes `dateFormat`, `language`, `enableDaySuffix`, `textToday`, `textDay` and `textDays`. +- **`shape`** adds `shapeType` of `rectangle` or `ellipse`, plus `fillColor`, `strokeColor`, `strokeWidth` and `cornerRadius`. +- **`image`** adds `imagePath`, a bare filename or an empty string meaning no source picked yet. + +`fontPath` and `imagePath` must be bare safe filenames. Anything with a path separator is rejected. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------- | +| `201` | Created. The body is the new template | +| `400` | Validation failed | +| `413` | The body exceeds the JSON body limit. A template with very many elements can reach it | +| `500` | The database write failed | + +This can never create a preset. Setting `isDefault: true` clears the flag on every other template of the same mode. + +### `PUT /api/overlays/templates/{id}` + +**Update a user template. Presets are rejected.** + +Every field is optional and omitted fields keep their stored value. + +| Status | Cause | +| ------ | -------------------------------------------- | +| `200` | Updated. The body is the updated template | +| `400` | `id` is not an integer, or validation failed | +| `404` | `Template not found or is a preset` | +| `413` | The body exceeds the JSON body limit | +| `500` | The database write failed | + +The `404` conflates "no such template" with "this is a preset". You cannot tell which from the response. + +:::caution Changing mode can leave two defaults +Other templates are only demoted when you explicitly send `isDefault: true`. + +Change the `mode` of a template that is already the default while omitting `isDefault`, and it stays default in its new mode **without** clearing the existing default there. That mode then has two defaults. The tie is broken by most recently updated, and it is only tidied up on the next restart. +::: + +Nothing is pushed to the media server. Artwork already overlaid is unaffected until the next run. + +### `DELETE /api/overlays/templates/{id}` + +**Permanently delete a user template. Presets are rejected.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ----------- | +| `id` | integer | Yes | Template id | + +| Status | Cause | +| ------ | ------------------------------------------ | +| `200` | Deleted. The body is `{ "success": true }` | +| `400` | `id` is not an integer | +| `404` | `Template not found or is a preset` | +| `500` | The database write failed | + +:::warning Destructive +The template and its elements are permanently deleted. **There is no undo and no soft delete.** Export it first with the export route if you might want it back. + +Collections pointing at it are not blocked from the delete. Their template reference is cleared and they fall back to the mode default on their next run. + +Artwork already overlaid on your media server stays overlaid, and revert and reset still work, because those use the saved backups rather than the template. +::: + +If the deleted template was the default for its mode, a replacement is promoted automatically, preferring a preset. Deleting the last template of a mode leaves it with no default at all, and collections in that mode then get no overlay. + +### `POST /api/overlays/templates/{id}/default` + +**Make one template the default for its own mode.** + +Takes no request body. The mode comes from the stored template, so this only ever moves the default within poster or within title card. + +| Status | Cause | +| ------ | ---------------------------------------------- | +| `201` | Default set. The body is the promoted template | +| `400` | `id` is not an integer | +| `404` | `Template not found` | +| `500` | The database write failed | + +Presets are allowed here. This is the only template change a preset accepts. + +The default is what a collection falls back to when it has no template of its own, or points at one of the wrong mode. The change only takes effect on the next overlay run. + +### `POST /api/overlays/templates/{id}/duplicate` + +**Copy a template, preset or not, into a new editable user template.** + +Takes no request body. The copy is named after the source with `(copy)` appended, and is always created as a non-preset, non-default template. + +| Status | Cause | +| ------ | ------------------------------------- | +| `201` | Created. The body is the new template | +| `400` | `id` is not an integer | +| `404` | `Template not found` | +| `500` | The database write failed | + +This is the supported way to get an editable copy of a built-in preset. The source is not modified. + +Duplicating repeatedly produces `X (copy) (copy)` and so on. The suffix is appended blindly with no length check. + +### `POST /api/overlays/templates/{id}/export` + +**Return a template as a portable, version-stamped JSON document.** + +Takes no request body. Despite being a `POST`, this only reads. + +Response: + +```json +{ + "version": 1, + "name": "My template", + "mode": "poster", + "canvasWidth": 1000, + "canvasHeight": 1500, + "elements": [] +} +``` + +`description`, `id`, `isDefault`, `isPreset` and the timestamps are deliberately dropped. + +| Status | Cause | +| ------ | ----------------------------------------- | +| `201` | Exported, even though nothing was created | +| `400` | `id` is not an integer | +| `404` | `Template not found` | +| `500` | The database read failed | + +This is plain JSON with no download headers. Presets export fine, which is the easiest way to fork one outside the app. + +:::caution The export is not self-contained +Elements reference fonts and images by bare filename. A template moved to another install renders with a fallback typeface and silently skipped image layers until you upload the matching files there too. +::: + +### `POST /api/overlays/templates/import` + +**Import a previously exported template as a new user template.** + +Send the exact document the export route produced. `version` must be exactly `1`. + +| Status | Cause | +| ------ | ---------------------------------------------------------- | +| `201` | Imported. The body is the new template | +| `400` | `version` is not `1`, or any field or element is malformed | +| `413` | The body exceeds the JSON body limit | +| `500` | The database write failed | + +Import always creates. It never matches or updates an existing template by name, so repeated imports of the same file produce duplicates with identical names. The result always lands with an empty description, and as a non-default, non-preset template. + +The import schema puts no maximum on `name`, while the create route caps it at 100 characters, so an import can produce a name longer than the create route would accept. + +### `POST /api/overlays/templates/{id}/preview` + +**Render a template over one item's real artwork and stream back the image.** + +| Parameter | Type | Required | Description | +| --------- | ------------- | -------- | -------------------------------------------------------------------------------------- | +| `id` | path, integer | Yes | Template id | +| `itemId` | query | **Yes** | Media server item whose artwork is the background. Get one from the random item routes | + +Returns `image/jpeg` bytes at the **source artwork's** pixel dimensions, not the template canvas dimensions. + +| Status | Cause | +| ------ | ---------------------------------------------------------------------------------------------------------- | +| `201` | The image. Note a successful render is `201`, not `200` | +| `400` | `itemId` is missing or empty, or `id` is not an integer | +| `404` | `Template not found` | +| `500` | No overlay provider, **the item has no artwork**, the image libraries are unavailable, or rendering failed | + +Date and day variables resolve to a fixed sample of 14 days, so the preview always shows the same countdown. + +Nothing is uploaded and nothing is written to disk. A preview leaves nothing for a revert or reset to undo, and in particular it does **not** create a backup. + +Note that "the item has no artwork" is a `500` here, not a `404`. + +## Preview helpers + +### `GET /api/overlays/sections` + +**List the movie and show libraries usable as overlay preview sources.** + +Response: + +```json +[{ "key": "1", "title": "Movies", "type": "movie" }] +``` + +| Status | Cause | +| ------ | -------------------------------------------------------------------------------------------------------- | +| `200` | The sections, possibly `[]` | +| `503` | `Overlays are not available right now.` when there is no overlay provider for the configured server type | + +An empty array is ambiguous: it means either no movie or show libraries exist, or the library read failed. An unreachable media server produces `200` with `[]` rather than an error. + +### `GET /api/overlays/random-item` + +**Pick one random movie or show from a library section, for poster preview.** + +| Parameter | Type | Required | Description | +| ----------- | ----- | -------- | --------------------------------------------------------------------- | +| `sectionId` | query | **Yes** | A `key` from `GET /api/overlays/sections`. Only one value is honoured | + +Response: + +```json +{ "itemId": "12345", "title": "An example title" } +``` + +| Status | Cause | +| ------ | ------------------------------------------------------- | +| `200` | An item, **or an empty body meaning nothing was found** | +| `400` | `sectionId` is missing or empty | +| `503` | No overlay provider for the configured server type | + +An unknown section id is not rejected. It simply matches nothing and gives you an empty body, as does an unreachable server. + +On Plex, "random" means random among the first 50 items in the section that have artwork. Jellyfin and Emby let the server pick. + +### `GET /api/overlays/random-episode` + +**Pick one random episode from a show section, for title card preview.** + +| Parameter | Type | Required | Description | +| ----------- | ----- | -------- | ------------------------------------------------------ | +| `sectionId` | query | **Yes** | A show library `key` from `GET /api/overlays/sections` | + +Returns the same shape as the random item route, with the title formatted as the series name, a dash, then the episode name. + +| Status | Cause | +| ------ | ------------------------------------------------------ | +| `200` | An episode, or an empty body meaning nothing was found | +| `400` | `sectionId` is missing or empty | +| `503` | No overlay provider for the configured server type | + +Passing a movie section simply matches nothing. Jellyfin and Emby exclude unaired placeholder episodes. Plex samples only the first 50 rows. + +### `GET /api/overlays/poster` + +**Proxy an item's current artwork from the media server.** + +A plain proxy so a browser can display media server artwork without holding a Plex token or an API key. No overlay is drawn. + +| Parameter | Type | Required | Description | +| --------- | ----- | -------- | -------------------- | +| `itemId` | query | **Yes** | Media server item id | + +| Status | Cause | +| ------ | ---------------------------------------------------------------------------------- | +| `200` | The artwork, cached by the browser for an hour | +| `400` | `itemId` is missing or empty | +| `404` | `Poster not found`: the item has no artwork, does not exist, or the request failed | +| `500` | Plex only: artwork was found but downloading it failed | +| `503` | No overlay provider for the configured server type | + +:::caution Two things to know about the bytes +The response is always labelled `image/jpeg` regardless of what the server actually sent. Only Jellyfin is guaranteed to match, because the format is forced there. A Plex or Emby PNG is served labelled as JPEG. + +It always returns what the media server holds **right now**, so for an already-processed item that is the overlaid poster, not the saved original. No route exposes the backups. +::: + +## Fonts and images + +Assets are stored on the server's data directory and referenced from templates by bare filename. Uploads are hardened against path traversal: a name like `../../evil.ttf` is reduced to `evil.ttf` inside the correct directory. + +### `GET /api/overlays/fonts` + +**List the font files available to the template editor.** + +Merges an uploaded font directory with the bundled fonts, with uploads taking precedence on a name collision. + +Response: + +```json +[ + { + "name": "Inter-Bold.ttf", + "path": "/opt/data/overlays/fonts/Inter-Bold.ttf" + } +] +``` + +| Status | Cause | +| ------ | ------------------------------------------ | +| `200` | The list, possibly empty | +| `500` | The directory exists but could not be read | + +Only `.ttf`, `.otf` and `.woff` are listed. `.woff2` is not supported, so such a file is invisible here and cannot be fetched. Files with unsafe names are hidden rather than offered and then rejected. + +Seven fonts ship with Maintainerr: Comfortaa Bold, Inter Bold, Medium and Regular, and Roboto Bold, Medium and Regular. + +### `POST /api/overlays/fonts` + +**Upload a font file.** + +Send `multipart/form-data` with a single file field named `font`. Accepted extensions are `.ttf`, `.otf` and `.woff`. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------------- | +| `201` | Stored. The body is the sanitised name and path | +| `400` | No file uploaded, an unsupported extension, an invalid filename, or the field is not named `font` | +| `500` | The write failed | + +:::warning Overwrites silently +A font with the same sanitised name is **overwritten with no warning and no backup**. Every template referencing that filename renders with the new file on the next run. +::: + +:::caution No size limit on this route +Unlike the image route, there is no size cap here and the whole file is buffered in memory. The bytes are also never checked to be a real font. A bad file only shows up later as a warning during a run, after which text falls back to a default typeface. +::: + +There is **no delete route for fonts**. An uploaded font can only be removed from disk by hand. + +### `GET /api/overlays/fonts/{name}` + +**Serve a single font file by filename.** + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ---------------------------------------------------------------------------------------- | +| `name` | string | Yes | A bare filename such as `Inter-Bold.ttf`. Letters, digits, dot, dash and underscore only | + +| Status | Cause | +| ------ | ---------------------------------------------------------------------------- | +| `200` | The font, cached by the browser for an hour | +| `400` | `Invalid font name`, which is how path traversal is rejected | +| `404` | `Font not found`, including a file that exists with an unsupported extension | + +Uploaded fonts shadow bundled ones of the same name. Because the response is cached for an hour, add a changing query parameter after re-uploading the same filename to defeat the cache. + +### `GET /api/overlays/images` + +**List the overlay image assets.** + +Response is the same `name` and `path` shape as the font list. Only `.png`, `.jpg`, `.jpeg` and `.webp` are listed, and only from the uploaded image directory. There are no bundled images. + +| Status | Cause | +| ------ | ------------------------------------------ | +| `200` | The list, possibly empty | +| `500` | The directory exists but could not be read | + +### `POST /api/overlays/images` + +**Upload an image asset for overlay image elements.** + +Send `multipart/form-data` with a single file field named `image`. + +| Requirement | Value | +| ------------ | ------------------------------------------------- | +| Extensions | `.png`, `.jpg`, `.jpeg`, `.webp` | +| Maximum size | **500 KB** | +| Content | Must genuinely be the format the extension claims | + +| Status | Cause | +| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `201` | Stored. The body is the sanitised name and path | +| `400` | No file, empty file, unsupported extension, not a valid image, contents that do not match the extension, an invalid filename, or a wrong field name | +| `413` | The file exceeds 500 KB | +| `500` | The write failed | +| `503` | The native image library is unavailable on this machine | + +A PNG renamed to `.jpg` is rejected, so the file cannot later be served with a misleading content type. + +:::warning Overwrites silently +An image with the same sanitised name is **overwritten with no warning and no backup**. This is a real in-place asset replacement. +::: + +The asset only reaches posters on the next overlay run, which reads it back from disk. + +### `GET /api/overlays/images/{name}` + +**Serve a single image asset by filename.** + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ------------------------------------------------------------ | +| `name` | string | Yes | A bare filename ending in `.png`, `.jpg`, `.jpeg` or `.webp` | + +| Status | Cause | +| ------ | ------------------------------------------------ | +| `200` | The image, sent with `Cache-Control: no-cache` | +| `400` | `Invalid image name` or `Unsupported image type` | +| `404` | `Image not found` | + +The `400` and `404` split is deliberate: an unsafe or unsupported name never reaches the filesystem, so it is distinguishable from a genuine miss. + +### `DELETE /api/overlays/images/{name}` + +**Permanently delete an overlay image asset from disk.** + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ---------------------------------------------------------------- | +| `name` | string | Yes | A bare filename. **No extension check is applied on this route** | + +| Status | Cause | +| ------ | ---------------------------------------------------- | +| `200` | Deleted. The body is `{ "success": true }` | +| `400` | `Invalid image name` | +| `404` | `Image not found` | +| `500` | The delete failed, for example a permissions problem | + +:::warning Destructive +Irreversibly removes the file from disk. **There is no undo and no trash.** + +The delete is not checked against templates. Any template referencing the filename is left pointing at a file that no longer exists, and nothing warns you at delete time. The breakage only shows up at render, where the missing image element is silently dropped from the overlay. + +Posters already burned with this image stay exactly as they are until the next run or reset. +::: + +Unlike the list and fetch routes, no extension filter applies here, so any safely-named file in the image directory can be deleted through this route. diff --git a/docs/api/rules.md b/docs/api/rules.md new file mode 100644 index 000000000..03108e469 --- /dev/null +++ b/docs/api/rules.md @@ -0,0 +1,715 @@ +--- +slug: /api/rules +title: Rules API +description: Rule groups, rule execution, exclusions, community rules, and YAML import and export. +--- + +Rule groups and everything around them: creating and editing them, running them, excluding media from them, and the shared community rule list. See the [Rules](../Rules.mdx) page for what rules do. + +See [API conventions](../API.md#api-conventions) for the rules that apply across the API. + +## What a rule run does, and does not do + +A rule run decides **membership**. It evaluates each item in the library against the group's rules and adds matching items to the collection or removes items that no longer match. + +It never deletes media. Deleting files, unmonitoring in an `*arr`, and cleaning up Seerr are done later by the [collection handler](./collections.md#post-apicollectionshandle), once an item's delete-after countdown has elapsed. + +The link between the two is the countdown: **adding an item to a collection starts its timer**. So a rule run is not itself destructive, but it is what puts media on the path to deletion. + +## The response envelope on this page + +Most write routes here answer with `code`, `result` and `message`, where `code` is `1` for success and `0` for failure: + +```json +{ "code": 1, "result": "Success", "message": "Success" } +``` + +This is not the same envelope as the `status` and `code` one used elsewhere. And on many of these routes a failure still comes back as `200` or `201`, so check `code` rather than the status line. Where a route converts a failure into a real HTTP error instead, that is noted. + +## Rule groups + +### `GET /api/rules` + +**List rule groups with their rules, collection and notification agents.** + +| Parameter | Type | Required | Description | +| ------------ | -------------- | -------- | --------------------------------------------------------------------------------------------------------------- | +| `activeOnly` | query | No | Only the exact string `true` filters to active groups. Anything else, including `1` and `TRUE`, means no filter | +| `libraryId` | query | No | Media server library id. **Takes precedence and makes `typeId` ignored entirely** | +| `typeId` | query, integer | No | Only applied when `libraryId` is absent | + +Each group carries `id`, `name`, `description`, `libraryId`, `isActive`, `collectionId`, `useRules`, `dataType`, `ruleHandlerCronSchedule`, plus nested `rules`, `notifications` and `collection`. + +| Status | Cause | +| ------------------------ | -------------------------------------- | +| `200` | The list | +| `200` with an empty body | The database read failed | +| `400` | `typeId` is present but not an integer | + +:::danger This route returns notification secrets in cleartext +Each group's `notifications[].options` is included unmasked. That can carry Discord, Slack and generic webhook URLs, Telegram bot tokens, Pushbullet and Pushover tokens, Gotify and ntfy tokens, webhook auth headers, and SMTP passwords and PGP keys. + +Unlike `GET /api/settings`, nothing is masked here. See [Security and Authentication](../Security.md). +::: + +:::caution typeId does not work +`typeId` is parsed as an integer, but the field it filters on holds a string such as `movie` or `show`. A numeric `typeId` therefore matches nothing and the route returns `[]`. Use `libraryId`, which is the only filter the web UI sends. +::: + +### `GET /api/rules/{id}` + +**Fetch one rule group with its rules, collection and notification agents.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------- | +| `id` | integer | Yes | Rule group id | + +| Status | Cause | +| ------------------------ | ----------------------------------------- | +| `200` | The group | +| `200` with an empty body | No group with that id, or the read failed | +| `400` | `id` is not an integer | + +There is no `404` here, which is the easiest thing to get wrong. Check for an empty body. + +Fields such as `arrAction`, `listExclusions`, `forceSeerr` and the `*arr` settings ids come back nested under `collection`, not at the top level. `rules[].ruleJson` is a JSON **string** and is not expanded for you. + +As with the list route, `notifications[].options` is unmasked. + +### `GET /api/rules/collection/{id}` + +**Fetch the rule group that owns a given collection.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | -------------------------------------- | +| `id` | integer | Yes | **Collection** id, not a rule group id | + +| Status | Cause | +| ------------------------ | ------------------------------------------------------ | +| `200` | The group | +| `200` with an empty body | No rule group owns that collection, or the read failed | +| `400` | `id` is not an integer | + +Unlike `GET /api/rules/{id}`, the response has **no `rules` array at all**. Use the other route if you need the rules. + +`notifications[].options` is unmasked here too. + +### `GET /api/rules/{id}/rules` + +**List the raw stored rule rows belonging to one rule group.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------- | +| `id` | integer | Yes | Rule group id | + +Each row is `id`, `ruleJson`, `ruleGroupId`, `section` and `isActive`. `ruleJson` parses to an object with `operator`, `action`, `firstVal`, and optionally `lastVal`, `customVal`, `arrDiskPath` and `username`. + +| Status | Cause | +| ------------------------ | ---------------------------------------------------- | +| `200` | The rows. An id that never existed also returns `[]` | +| `200` with an empty body | The read failed | +| `400` | `id` is not an integer | + +`section` groups rules into blocks, and `operator` is `0` for AND, `1` for OR, and null on the first rule of a section. `isActive: false` means the rule is stored but skipped during evaluation. + +Rows come back in no guaranteed order, unlike the other two routes which order by id. Do not rely on the order when reconstructing sections. + +### `GET /api/rules/count` + +**Return the total number of rule groups.** + +The response is a bare number sent as **plain text**, not JSON. + +| Status | Cause | +| ------ | ------------------------ | +| `200` | The count | +| `500` | The database read failed | + +This counts every row with no filtering, so it will not agree with the length of `GET /api/rules`, which filters. + +### `POST /api/rules` + +**Create a rule group, its collection and its rules.** + +The body is a rule group object. Required are `libraryId`, `name`, `description`, `dataType` and `rules`. It also accepts `isActive`, `useRules`, `ruleHandlerCronSchedule`, `notifications` (only each entry's `id` is used), and a nested `collection` block carrying `deleteAfterDays`, `manualCollection`, `manualCollectionName`, `visibleOnRecommended`, `visibleOnHome`, `keepLogsForMonths`, `sortTitle`, `mediaServerSort`, `overlayEnabled` and `overlayTemplateId`. + +The body is **not** schema-validated. Unknown fields pass through, and missing ones are only caught by the hand-written checks below. + +| Status | Cause | +| ------ | ----------------------------------------------------------------------------------------------- | +| `201` | Created | +| `400` | A validation check failed. The message says which | +| `500` | The collection could not be created, or the save failed | +| `502` | `No libraries could be read from the media server. Check its connection in the settings.` | +| `503` | Credentials are not saved, the adapter failed to start, or a media server switch is in progress | + +Validation covers a lot: every rule after the first needs an operator, values must exist on the selected server and their types must match, an action must be supported for the type, a collection cannot be managed by both Sonarr and Sportarr, `deleteAfterDays` must be a whole number from `0` to `36500`, rules for a given `*arr` need that server selected, a disk target path is only allowed on disk space rules, a username is only allowed on per-user properties and must actually exist on the media server, and the library must exist. + +Creating a rule group does **not** run it. The collection stays empty until a run fills it. + +:::caution Omitting useRules saves a group with no rules +The stored group defaults `useRules` to `true`, but the rule rows are only written when you actually send `useRules`. Omitting it therefore saves a group flagged as using rules that has none behind it. +::: + +Some values you send are deliberately overruled: `cleanupLeftoverFolders` is forced off unless the chosen action can actually strand a folder, `forceSeerr` is forced off for an episode collection, and `keepInMaintainerrOnly` is forced off for a manual collection. `dataType` only matters for a TV library, since a movie library always produces a movie collection. + +### `PUT /api/rules` + +**Update an existing rule group and rewrite all of its rules.** + +Same body as the create route plus a required `id`. + +| Status | Cause | +| ------ | ---------------------------------------------------------------------------------- | +| `200` | Updated | +| `400` | A validation check failed, or `id` is missing | +| `404` | `Rule group not found` | +| `500` | The collection could not be saved, or the save failed | +| `502` | The media server's library list could not be read | +| `503` | Credentials are not saved, the adapter failed to start, or a switch is in progress | + +:::warning Destructive: changing four fields wipes membership and exclusions +If `dataType`, `libraryId`, `manualCollection` or `manualCollectionName` differs from what is stored, Maintainerr treats it as a change of identity and, before saving anything: + +- **deletes every membership row** for the collection, +- **deletes every exclusion scoped to this rule group**, +- releases the collection on the media server, deleting an automatic one outright, or removing just this collection's items from one a sibling group shares. A manual collection is left alone, +- clears the link. + +**None of that is recoverable.** The collection is rebuilt from scratch by the next run, and the exclusions have to be recreated by hand. + +No media files are deleted and nothing leaves your library. +::: + +This always rewrites every rule row from the payload. It is a full replace. + +:::caution Omitting the collection block loses settings +Omitting `collection` keeps the stored `visibleOnRecommended`, `visibleOnHome`, `manualCollection` and `manualCollectionName`. But `deleteAfterDays`, `sortTitle`, `mediaServerSort`, `overlayEnabled` and `overlayTemplateId` fall back to empty rather than to their stored values, and `keepLogsForMonths` resets to `6`. + +Send the full object, including the collection block, on every update. +::: + +Deactivating a group by sending `isActive: false` drops its cron job and removes it from the run queue, but leaves the collection's contents in place. + +### `DELETE /api/rules/{id}` + +**Delete a rule group together with its collection and exclusions.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------- | +| `id` | integer | Yes | Rule group id | + +| Status | Cause | +| -------------------- | --------------------------------------------------------------------------- | +| `200` with `code: 1` | Deleted. **An id that does not exist also reports success** | +| `200` with `code: 0` | The media server refused to delete the collection, or something else failed | +| `400` | `id` is not an integer | + +:::warning Destructive +Deletes the rule group, its rules and its notification links, **and its whole collection**: every membership row, the entire collection log, and every exclusion scoped to this group. The stored collection poster is deleted from disk, and overlays are reverted first. + +On your media server, an automatic linked collection is **deleted**. One shared with a sibling group has only this collection's items removed. A manual collection is left alone, since it is yours. + +**None of this is reversible.** Recreating the rule group starts from an empty collection. Global exclusions survive. + +No media files are deleted and nothing leaves your library. +::: + +If the media server refuses the delete, **nothing at all** is removed and the group stays intact so you can fix the server setting and retry. + +Any queued or in-flight run for the group is cancelled. + +## Running rules + +### `POST /api/rules/execute` + +**Queue every active rule group for immediate execution.** + +Takes no request body. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------------------------------ | +| `201` | The groups were **queued**. Fire and forget | +| `409` | The executor is already running, no rule groups exist, none are active, or every active group is missing a library | + +:::warning Destructive to collection membership +A run adds items to and removes items from collections, on both sides: Maintainerr's records and the linked collection on your media server. It also reconciles `*arr` membership tags, and fires notification agents. + +**No media files are deleted and nothing leaves your library.** But adding an item **starts its delete-after countdown**, and the collection handler acts on it later. +::: + +A `201` only means the groups were queued. Poll `GET /api/rules/execute/status`. + +Exclusions are applied at membership time, so an excluded item, or a child covered by an ancestor's exclusion, is filtered out of the results and therefore removed from the collection. Items whose rule data was temporarily unavailable are kept rather than removed. + +If the media server is unreachable when the queue starts, the whole queue is silently dropped. If it becomes unreachable partway, the rest of the queue is dropped with a warning. + +The pre-flight check only requires **one** active group to have a library, but every active group is queued, so library-less groups still run and fail. + +### `POST /api/rules/{id}/execute` + +**Queue one rule group for immediate execution.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------- | +| `id` | integer | Yes | Rule group id | + +| Status | Cause | +| ------ | --------------------------------------------------------------------------------- | +| `201` | Queued. Fire and forget | +| `400` | `id` is not an integer | +| `404` | `Rule group not found` | +| `409` | The group is not active, has no library assigned, or is already running or queued | + +:::warning Destructive to collection membership +The same effects as the global run, scoped to one group. Adding an item **starts its delete-after countdown**. No media files are deleted by this route. +::: + +Unlike the global run, this queues even while another group is executing. It only rejects a duplicate of the same group. + +### `GET /api/rules/execute/status` + +**Report whether the rule executor queue is draining and which groups are running or waiting.** + +Response: + +```json +{ + "processingQueue": true, + "executingRuleGroupId": 3, + "pendingRuleGroupIds": [4], + "queue": [5, 6] +} +``` + +| Status | Cause | +| ------ | ------------------------------- | +| `200` | Always. The handler cannot fail | + +Polling this is the only way over HTTP to know a run finished, because the execute routes are fire and forget. + +The three id fields are different sets. `queue` is work not yet claimed, `pendingRuleGroupIds` is claimed but waiting on the lock, and `executingRuleGroupId` is the one actually running and is excluded from the pending list. + +:::caution processingQueue is about rules only +A collection handler run holds the same lock but does **not** set this flag. `processingQueue: false` therefore does not mean nothing is handling collections. +::: + +All of this is in memory and resets on restart, so a run interrupted by a restart leaves no trace. + +### `POST /api/rules/execute/stop` + +**Stop the running rule executor and clear its queue.** + +Takes no request body. The response body is always empty, so the status is the only signal. + +| Status | Cause | +| ------ | ---------------------------------------------------------------------------------- | +| `200` | Nothing was running. No action taken | +| `202` | A stop was **requested**. The queue is cleared and the in-flight group is aborting | + +`202` means requested, not stopped. The run stops at its next checkpoint. Poll the status route. + +Aborting mid-run leaves whatever membership changes were already made in place. There is no rollback, and a collection can be left half-reconciled until the next run. + +### `POST /api/rules/{id}/execute/stop` + +**Stop or dequeue one rule group's execution.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------- | +| `id` | integer | Yes | Rule group id | + +| Status | Cause | +| ------ | --------------------------------------------------------------------------- | +| `200` | The group was neither running nor queued. Nothing done | +| `202` | Stop requested. The group is dequeued, and aborted if it was the active run | +| `400` | `id` is not an integer | + +A stopped group is not re-queued automatically. It waits for its own schedule or the global one. + +### `POST /api/rules/test` + +**Evaluate one rule group against a single media item and return the comparison breakdown.** + +Request body: + +```json +{ "mediaId": "12345", "rulegroupId": 1 } +``` + +Note the lower-case `g` in `rulegroupId`. `mediaId` may be a show, season or episode id. + +Response on success is `code: 1` and a `result` array. Each entry carries `mediaServerId`, an overall `result` boolean, and `sectionResults`, each holding per-rule results with `firstValueName`, `firstValue`, `secondValue`, `action`, `operator` and `result`, plus reason strings when a value could not be read. + +| Status | Cause | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `201` | Handled. `code: 0` with a reason for `Rule group not found`, `Rule group does not use rules`, `Invalid input` when the item's metadata could not be read, or an evaluation error | +| `500` | No media server type configured, or a metadata read threw | +| `503` | Credentials are not saved, the adapter failed to start, or a switch is in progress | + +This only reports whether the item matches. It never changes collection membership. + +:::caution Read-only, but not free +To see live data it **flushes shared caches process-wide**: the Seerr, Tautulli, Streamystats and every Radarr, Sonarr and Sportarr cache, plus this item's metadata entry. + +A concurrent or subsequent rule run then has to re-fetch everything. This is also not serialised against the execution lock, so it can run during a rule run. +::: + +A missing property or an unconfigured service shows up as a per-rule reason string rather than an error. + +## Exclusions + +An exclusion stops a rule run from adding an item back to a collection. It does **not** remove anything already in a collection, and it does not delete anything. + +An exclusion with no rule group is **global** and protects the item in every rule group. One with a rule group is scoped to that group only. + +### `GET /api/rules/exclusion` + +**List exclusion rows for one rule group or for one media item.** + +| Parameter | Type | Required | Description | +| --------------- | -------------- | -------- | -------------------------------------------------------------------------------------------- | +| `rulegroupId` | query, integer | No | That group's exclusions **plus every global one**. Takes precedence. Note the lower-case `g` | +| `mediaServerId` | query | No | Rows whose id or `parent` matches. Ignored when `rulegroupId` is present | + +| Status | Cause | +| ------------------------ | ---------------------------------------------------------------------------------- | +| `200` | The rows. **Calling with no parameters returns `[]`**, it does not list everything | +| `200` with an empty body | The read failed | +| `400` | `rulegroupId` is present but not an integer | + +There is no route that dumps every exclusion. + +:::caution parent is not a hierarchy link +`parent` records the id the original exclusion request **entered through**, not the structural parent. A season excluded directly stores its own id as `parent`, so querying by the show id will not surface it. +::: + +`type` can be null on exclusions created before the column existed. Maintainerr backfills those at startup, but only when the media server is reachable. + +### `POST /api/rules/exclusion` + +**Add or remove one exclusion for a media item, cascading to its children.** + +Request body: + +```json +{ "mediaId": "12345", "collectionId": 1, "action": 0 } +``` + +| Field | Type | Required | Description | +| -------------- | ------ | -------- | --------------------------------------------------------------------- | +| `mediaId` | string | Yes | Media server item id | +| `action` | number | No | `0` adds (the default), `1` removes | +| `collectionId` | number | No | Scopes the exclusion to that collection's rule group | +| `ruleGroupId` | number | No | Scopes directly. **Overwritten by `collectionId` when both are sent** | +| `context` | object | No | `id` and `type`, narrowing the action to one season or episode | + +Nothing in this body is validated. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `201` | Handled, **including every business failure**: `Failed - no rule group`, `Failed - no metadata`, `Failed - media server unreadable`, or `Failed` | +| `409` | The 30-second wait for the execution lock expired. Adds only | +| `500` | No media server type configured, or the metadata read threw | +| `503` | Credentials are not saved, the adapter failed to start, or a switch is in progress | + +:::warning Destructive +Writes or deletes exclusion rows for the item and everything it cascades to, and can apply or strip the protective Radarr or Sonarr tag. + +Removing an exclusion re-exposes the item: the next run can add it back to the collection and **start its delete-after countdown**, which is the way back onto the deletion path. + +No media files are deleted and nothing is removed from a collection or a library. +::: + +Unlike the bulk route, this does **not** take the item out of the collection. It only writes the exclusion, so the item stays a member until the next run filters it out. + +:::danger A removal with no scope deletes every exclusion for the item +On the remove path, sending neither `collectionId` nor `ruleGroupId` deletes **every** exclusion row for each resolved id, global and every rule group's alike. Always scope a removal unless you really mean all of them. +::: + +Global exclusions subsume scoped ones. A scoped add is skipped when a global row already exists, and a global add deletes the item's scoped rows. + +### `POST /api/rules/exclusions/bulk` + +**Add or remove exclusions for up to 250 media items, reporting per item.** + +This is the route the web UI uses for everything. + +Request body: + +```json +{ + "mediaIds": ["12345", "12346"], + "collectionId": 1, + "action": 0, + "context": { "id": "12345", "type": "season" } +} +``` + +| Field | Type | Required | Description | +| -------------- | -------- | -------- | ----------------------------------------------------------------------- | +| `mediaIds` | string[] | Yes | 1 to 250 ids | +| `action` | number | No | `0` adds (the default), `1` removes | +| `collectionId` | number | No | Scopes to that collection's rule group. **Omit for a global exclusion** | +| `context` | object | No | Narrows a one-item selection. Only allowed with exactly one id | + +Response: + +```json +{ "results": [{ "mediaId": "12345", "code": 1 }] } +``` + +One entry per unique id, in first-appearance order. Possible failure messages include `Failed - no rule group`, `Failed - no metadata`, `Failed - media server unreadable`, `Failed - see server logs`, `Excluded, but not removed from the collection` and `Excluded, but not removed from every collection`. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `201` | Processed. Per-item outcomes are in the body | +| `400` | Empty list, more than 250 ids, a blank id, a bad `collectionId` or `action`, or a `context` with more than one id. **Nothing is processed** | +| `409` | The 30-second wait for the execution lock expired. Adds only | +| `500` | No media server type configured, or a metadata read on the add path threw | +| `503` | Credentials are not saved, the adapter failed to start, or a switch is in progress | + +:::warning Destructive +**An add does two things**: it writes the exclusion **and removes the item from the collection**, in that order, so a failed exclusion never silently removes anything. + +With no `collectionId` the add is global and removes the items from **every** collection, clearing manual membership as well as rule-added membership. + +Exclusion rows and collection membership are affected, along with Radarr and Sonarr protective tags. Media files are never deleted and nothing is removed from your library. +::: + +Removing an exclusion also picks up rows whose `parent` matches, which is how a cascaded show exclusion is fully cleared. A scoped removal skips rows belonging to a different rule group. + +The 250 cap applies to one request. The web UI sends 25 at a time, so only direct API callers reach it. + +### `DELETE /api/rules/exclusion/{id}` + +**Delete one exclusion row by its database id.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------------------------------------------- | +| `id` | integer | Yes | Exclusion row id, from `GET /api/rules/exclusion` | + +| Status | Cause | +| -------------------- | ---------------------------------------------------------- | +| `200` with `code: 1` | Deleted. **A row that did not exist also reports success** | +| `200` with `code: 0` | Something failed | +| `400` | `id` is not an integer | + +:::warning Destructive +Removing an exclusion re-exposes the item to its rule group. The next run can add it back to the collection and **start its delete-after countdown**. + +The protective Radarr or Sonarr tag may also be removed, but only when no exclusion for that item remains. +::: + +This deletes exactly one row. An exclusion that cascaded from a show to its seasons and episodes is only partially removed. Use the route below or the bulk route to clear the whole cascade. + +### `DELETE /api/rules/exclusions/{mediaServerId}` + +**Delete every exclusion for one media item and all of its children.** + +| Parameter | Type | Required | Description | +| --------------- | ------ | -------- | ----------------------------------- | +| `mediaServerId` | string | Yes | Media server item id. Not validated | + +| Status | Cause | +| -------------------- | ---------------------------------------------------------------------------------- | +| `200` with `code: 1` | Deleted | +| `200` with `code: 0` | `Failed - no metadata`, or a delete threw | +| `500` | The metadata read or the child walk threw, or no media server is configured | +| `503` | Credentials are not saved, the adapter failed to start, or a switch is in progress | + +:::warning Destructive: this is the widest un-exclude +It deletes every exclusion for the item **and every descendant**, in **every scope**. Global rows and every rule group's rows go together. + +One call can therefore drop protections that several different rule groups rely on, re-arming all of them to re-collect the item and eventually act on it. **This cannot be undone**, and unlike the other exclusion routes it writes **no collection log entry**, so the collection's history shows no trace of it. + +The protective Radarr and Sonarr tags are removed from every configured instance too. +::: + +## Rule editor data + +### `GET /api/rules/constants` + +**Return the catalogue of applications and their comparable properties.** + +This is what fills the rule editor's dropdowns. Applications the install cannot use are filtered out based on saved settings only. Reachability is never probed. + +The response is an `applications` array. Each application has `id`, `name`, `mediaType` and `props`, and each property has `id`, `name`, `humanName`, `mediaType`, a `type` describing the comparisons it supports, and optionally `showType`. + +| Status | Cause | +| ------ | ------------------------ | +| `200` | The catalogue | +| `500` | The settings read failed | + +Filtering is by configuration: Seerr needs a URL and API key, each `*arr` needs a saved instance, Tautulli needs a URL and API key **and** a Plex server, Streamystats needs a URL, the Jellyfin API key **and** a Jellyfin server, and Tracearr needs a URL, API key and server id. + +:::caution The media server applications are never filtered +Plex, Jellyfin and Emby all come back regardless of which one is configured, and on a fresh install with no settings row **nothing at all** is filtered. + +The web UI narrows the list itself. A different API client that trusts this list verbatim will offer properties its server cannot answer. +::: + +### `GET /api/rules/users` + +**List the media server usernames a per-user rule can be scoped to.** + +Response is a sorted, deduplicated array of strings. + +| Status | Cause | +| ------ | ----------------------------------------- | +| `200` | The usernames, **or `[]` on any failure** | + +An empty array is ambiguous: no users, no media server configured, a switch in progress, missing credentials, or the account list could not be read. + +On Plex these are deliberately the plex.tv spellings rather than the local account names, because that is the only naming the media server and Tautulli agree on. If plex.tv is unreachable, Plex returns `[]` rather than the wrong local names, on purpose. + +## Import and export + +### `POST /api/rules/migrate` + +**Rewrite imported rules to target the configured media server.** + +Request body: + +```json +{ "rules": "[{\"action\":0,\"firstVal\":[0,1],\"section\":0}]" } +``` + +`rules` is a **JSON-encoded string**, not an array. + +| Status | Cause | +| ------ | --------------------------------------------------------------------------------------------------------- | +| `201` | Always. `code: 1` with the migrated array as a JSON string in `result`, or `code: 0` with `Invalid input` | + +Properties specific to one media server are remapped to the equivalent on the target. A rule whose property has no equivalent is dropped and counted as skipped. Section operators are preserved so a dropped rule cannot flip a section between AND and OR. + +Skipped rules are not reported in the body. Compare the input and output array lengths to detect them. + +Non-media-server applications, such as the `*arr`s, Seerr, Tautulli and Tracearr, are never touched. Nothing is saved. + +### `POST /api/rules/yaml/encode` + +**Serialise a set of rules to the shareable YAML format.** + +Request body: + +```json +{ "rules": "[]", "mediaType": "movie" } +``` + +`rules` is a JSON-encoded string. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------------------------------------- | +| `201` | Always. `code: 1` with the YAML in `result` and a `skipped` count, or `code: 0` with `Invalid input` or an export failure | + +`skipped` counts rules dropped because their property does not exist on this build or server, so an export can be quietly incomplete. Always check it. + +### `POST /api/rules/yaml/decode` + +**Parse a YAML rule export back into rule objects for the configured server.** + +Request body: + +```json +{ "yaml": "...", "mediaType": "movie" } +``` + +`mediaType` must match the document's own media type. + +| Status | Cause | +| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `201` | Always. `code: 1` with the decoded rules as a JSON string and a `skipped` count, or `code: 0` with `Yaml import failed. Incompatible media types.`, `Validation failed - Please check your YAML structure.`, or `Failed to import rules` | + +A rule whose property identifier cannot be resolved is skipped rather than failing the import, and the decoded rules are then migrated to the configured server. `skipped` merges both counts, so success does not mean the whole document survived. + +The decoded rules are not checked against the save validation, so an import can still be rejected by the create or update route afterwards. + +## Community rules + +These routes talk to the public community rule service shared by every Maintainerr install. + +### `GET /api/rules/community` + +**Fetch the shared community rule list.** + +| Status | Cause | +| ------------------------ | ----------------------------------------------------------------------------------------------- | +| `200` | An array of community rules | +| `200` with `code: 0` | The service was unreachable or errored. **The body shape changes from an array to an envelope** | +| `200` with an empty body | The service answered but the payload had no rules | + +:::caution Three different body shapes on one status code +Success, failure and an unexpected payload are all `200` with different bodies. Always check `Array.isArray()` before treating the response as a list. + +There is no timeout on the outbound request, so an unresponsive community host can hold your request open for a long time across several retry attempts. Nothing is cached, so every call goes out to the network. +::: + +### `GET /api/rules/community/count` + +**Return how many rules the community list holds.** + +The response is a bare number as **plain text**, not JSON. + +| Status | Cause | +| ------ | -------------------------------- | +| `200` | The count, or `0` on any failure | + +`0` means either "no community rules" or "the fetch failed", with no way to tell them apart. + +There is no upstream count endpoint, so this downloads the entire list to measure it. Fetching the count costs exactly as much as fetching the list. + +### `POST /api/rules/community` + +**Publish a rule set to the public community rule list.** + +Request body needs `name`, `description` and `JsonRules`. Nothing is validated, so extra fields are forwarded to the community service as-is. + +| Status | Cause | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `201` | Always. `code: 1` with `Success`, or `code: 0` with `Invalid input`, `Connection failed`, `Name already exists`, or `Saving community rule failed` | + +:::warning Publishes publicly and permanently +The rule is appended to a list **visible to every Maintainerr install**, and there is no delete or edit endpoint. It cannot be removed through this API. + +Every `username` in the submitted rules is stripped before publishing, because a per-user rule names an account in your own household that would not resolve anywhere else. +::: + +The duplicate-name check reads before it writes with no locking, so two installs racing on the same name can both succeed. The generated id is the list length at the time, so it is not a stable identifier. + +### `POST /api/rules/community/karma` + +**Vote on a community rule's karma, once per rule per install.** + +Request body: + +```json +{ "id": 12, "karma": 6 } +``` + +`karma` is the **absolute new value**, not a change. The UI computes it as the current karma plus or minus one. + +| Status | Cause | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `201` | Always. `code: 1` with `Success`, or `code: 0` with `Invalid input`, `Connection failed`, `Rule not found`, `Already updated Karma for this rule`, or an update failure. Above `990` you get `code: 1` and `Success, but Max Karma reached for this rule.` with nothing written | + +Because karma is absolute, a client sending a stale base value overwrites whatever other installs have voted since. + +The one-vote-per-rule guard is local only, so the same rule can be voted on again from another install or after the database is reset. + +### `GET /api/rules/community/karma/history` + +**List which community rules this install has already voted on.** + +Response: + +```json +[{ "id": 1, "community_rule_id": 12 }] +``` + +| Status | Cause | +| ------ | ------------------------------------------ | +| `200` | The vote markers, empty on a fresh install | +| `500` | The database read failed | + +Despite the name this is not a ledger. There is no score, no timestamp and no rule name, only a record that a vote happened, used to stop a second vote on the same rule. diff --git a/docs/api/seerr.md b/docs/api/seerr.md new file mode 100644 index 000000000..64d26237d --- /dev/null +++ b/docs/api/seerr.md @@ -0,0 +1,175 @@ +--- +slug: /api/seerr +title: Seerr API +description: Seerr lookups, requester names, and request and media deletion. +--- + +Proxies to the configured Seerr instance, plus three routes that delete things there. + +:::info Alias paths +Every route on this page answers on three prefixes: `/api/seerr`, `/api/overseerr` and `/api/jellyseerr`. They are pure aliases of the same handler, kept for backward compatibility. The `/api/seerr` form is canonical and is the one used below. +::: + +These routes talk to Seerr only. They behave identically whether Maintainerr is wired to Plex, Jellyfin or Emby. + +See [API conventions](../API.md#api-conventions) for the rules that apply across the API. + +:::note Failures look like success +Nothing on this page returns a `4xx` or `5xx` for an upstream problem. An unreachable Seerr, a bad API key, an unknown id, and Seerr not being configured at all collapse into either an empty `200` body or an empty array. Plan for that: you cannot tell those cases apart from the response. +::: + +Responses are cached in memory for 20 minutes. The deleting routes do **not** invalidate that cache, so a read straight after a delete can still show the deleted record. + +## Lookups + +### `GET /api/seerr/movie/{id}` + +**Fetch a movie's record from the configured Seerr instance by TMDB id.** + +A pass-through proxy. Seerr's own movie JSON is forwarded unchanged, with no reshaping and no field stripping. + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ----------------------------------------------------------- | +| `id` | string | Yes | TMDB movie id, not Seerr's internal media id. Not validated | + +Maintainerr reads only part of the payload: `id`, `releaseDate` and `mediaInfo`. Within `mediaInfo` it uses `id`, which is Seerr's internal media-row id, along with `tmdbId`, `tvdbId`, `status` and `requests`. Each request carries `id`, `status`, `createdAt`, `updatedAt`, `requestedBy` and `modifiedBy`. Request `status` is `1` pending, `2` approved, `3` declined, `4` failed, `5` completed. + +A movie Seerr does not track still returns a body, with `mediaInfo` absent or null. + +| Status | Cause | +| ------------------------ | --------------------------------------------------------------------------------------------------- | +| `200` | Seerr's JSON body | +| `200` with an empty body | Unknown id, Seerr returned an error, Seerr was unreachable or timed out, or Seerr is not configured | + +### `GET /api/seerr/show/{id}` + +**Fetch a show's record from the configured Seerr instance by TMDB id.** + +The same pass-through proxy for shows. Note the naming mismatch: this route is `show`, while the Seerr endpoint behind it is `tv`. + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ---------------------------------------------------------- | +| `id` | string | Yes | TMDB show id, not Seerr's internal media id. Not validated | + +On top of the movie fields, `mediaInfo` carries `seasons`, and each request carries a `seasons` array of `id`, `name`, `seasonNumber` and `status`. A show Seerr does not track returns a body with `mediaInfo` null. + +| Status | Cause | +| ------------------------ | --------------------------------------------------------------------------------------------------- | +| `200` | Seerr's JSON body | +| `200` with an empty body | Unknown id, Seerr returned an error, Seerr was unreachable or timed out, or Seerr is not configured | + +There is no route for fetching a single season. + +### `GET /api/seerr/requests/{tmdbId}/users` + +**List the deduplicated usernames of everyone who requested a title, optionally narrowed to one season.** + +This is the route behind the "Requested by" line in the media modal. Names are resolved as the Plex username, then the Jellyfin username, then the plain Seerr username. + +| Parameter | Type | Required | Description | +| --------- | -------------- | -------- | ------------------------------------------------------------------------ | +| `tmdbId` | path, integer | Yes | TMDB id of the movie or show | +| `season` | query, integer | No | Season number. Only filters show requests, so it is harmless for a movie | + +Response: + +```json +["example-user", "another-user"] +``` + +Order is oldest request first. + +| Status | Cause | +| ------ | --------------------------------------------------------------------- | +| `200` | Array of usernames, possibly empty | +| `400` | `tmdbId` is not an integer, or `season` is present but not an integer | + +Pass `season` for a season-level lookup. Seerr tracks show requests per season, so without it a season lookup credits whoever requested a _different_ season. + +Watch the empty-value case: `?season=` with nothing after it is not the same as omitting the parameter. It fails validation and returns `400`. Omit the parameter entirely instead. + +An empty array conflates three states: nobody requested the title, Seerr is down, and Seerr is not configured. This is deliberate, so that a pre-deletion notification is never suppressed just because the requester could not be named. + +:::caution Cost trap +The first call after the cache is cleared sweeps **every** request in Seerr, 100 at a time, to build an index. Opening a media modal can therefore trigger a full request prefetch. Concurrent first callers are collapsed onto a single sweep, and a failed sweep is not cached so the next call retries. The index is held for an hour and is cleared at the start of every rule group run. +::: + +## Deletion + +The three routes below change data on your Seerr instance. None of them require authentication, and none of them ask for confirmation. + +### `DELETE /api/seerr/request/{requestId}` + +**Delete a single request on the configured Seerr instance.** + +| Parameter | Type | Required | Description | +| ----------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------- | +| `requestId` | string | Yes | Seerr's own request id, taken from `mediaInfo.requests[].id`. Not a TMDB id and not a media id. Not validated | + +| Status | Cause | +| ------------------------ | -------------------------------- | +| `200` with an empty body | Always. The delete was attempted | + +:::warning Destructive +Deletes the named request on your Seerr instance. **This cannot be undone from Maintainerr**, and the request can only be restored by making it again in Seerr. + +It does not remove the media record, delete any file, or touch your media server or `*arr` instances. Seerr keeps the media row until it is deleted separately. +::: + +Success and failure are indistinguishable. Seerr answers these deletes with an empty `204`, and every error is swallowed, so you always get the same empty `200`. Check Seerr itself to confirm. + +### `DELETE /api/seerr/media/{mediaId}` + +**Delete a media record on the configured Seerr instance by Seerr's internal media id.** + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `mediaId` | string | Yes | Seerr's internal media-row id, which is the `mediaInfo.id` field of a movie or show lookup. **Not a TMDB id.** Not validated | + +| Status | Cause | +| ------------------------ | -------------------------------- | +| `200` with an empty body | Always. The delete was attempted | + +:::warning Destructive +Deletes the media record on your Seerr instance, **which takes all of that title's requests with it**. This cannot be undone from Maintainerr. + +No file is deleted, and nothing changes in Maintainerr's database, on your media server, or in any `*arr` instance. +::: + +:::danger Wrong ids fail silently +Seerr answers `204` for an id it does not hold, so passing a TMDB id here, or an id that was already deleted, is a silent no-op that looks exactly like a successful deletion. + +If all you have is a TMDB id, use `DELETE /api/seerr/media/tmdb/{mediaId}` instead, which does the lookup for you and reports whether anything was deleted. +::: + +There is one route-matching quirk worth knowing: a bare `DELETE /api/seerr/media/tmdb` with no id after it lands on **this** handler with `mediaId` set to the literal string `tmdb`, and that is sent to Seerr as-is. + +### `DELETE /api/seerr/media/tmdb/{mediaId}` + +**Look a movie up in Seerr by TMDB id and delete its media record.** + +Resolves the title through Seerr's movie lookup and then deletes the media record it points at. Unlike the other two deletion routes, this one reports what happened. + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ------------------------------------------------------------------------- | +| `mediaId` | string | Yes | TMDB id of a **movie**, despite the generic parameter name. Not validated | + +The body is the bare text `true` or `false`, not JSON. + +| Status | Cause | +| ------------------------ | ------------------------------------------------------------------------------------------------------- | +| `200` with body `true` | Seerr had a media record for that movie and it was deleted | +| `200` with body `false` | Seerr answered, but the movie has no media record, so there was nothing to delete | +| `200` with an empty body | The state could not be established: unknown id, Seerr unreachable, bad API key, or Seerr not configured | + +:::warning Destructive +Deletes the resolved media record on your Seerr instance, taking that title's requests with it. This cannot be undone from Maintainerr. + +No file is deleted and nothing changes on your media server or in any `*arr` instance. +::: + +:::danger Movies only +This route hardcodes the movie type, so a TMDB id is always looked up as a movie. + +TMDB numbers movies and shows independently. Passing a **show's** TMDB id here does not fail. It resolves to whatever unrelated movie happens to carry that id, and deletes that movie's Seerr record instead. There is no route for the show variant. +::: diff --git a/docs/api/servarr.md b/docs/api/servarr.md new file mode 100644 index 000000000..f0c721bc5 --- /dev/null +++ b/docs/api/servarr.md @@ -0,0 +1,150 @@ +--- +slug: /api/servarr +title: Servarr API +description: Radarr, Sonarr and Sportarr disk space and quality profile lookups. +--- + +Read-only lookups against a configured Radarr, Sonarr or Sportarr instance. The rule editor uses them to fill its disk-path picker and quality-profile dropdown. + +In every path here, `{id}` is the **Maintainerr settings row id** for that instance, not an id from the `*arr` side. Get it from `GET /api/settings/radarr`, `GET /api/settings/sonarr` or `GET /api/settings/sportarr`. + +None of these depend on which media server is configured. + +See [API conventions](../API.md#api-conventions) for the rules that apply across the API. + +:::note Two different failure styles +The disk-space routes fail **closed**: an unreachable instance is a `500`, never a partial answer. The profile routes fail **open**: an unreachable instance returns `200` with an empty array, which you cannot tell apart from an instance that genuinely has no profiles. + +The difference is deliberate. A half-failed disk-space read once reported 3.9 GB free where the instance had 15.4 GB, which was enough to fire a free-space deletion rule. +::: + +## Disk space + +### `GET /api/servarr/radarr/{id}/diskspace` + +**Return the disk mounts of one configured Radarr instance, merged with its root folders.** + +Reads the instance's disk space and root folders in parallel and merges any root-folder path the disk-space report did not already cover. + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------------------------------ | +| `id` | integer | Yes | Maintainerr's Radarr settings row id | + +Response: + +```json +[ + { + "id": 1, + "path": "/movies", + "label": "/", + "freeSpace": 0, + "totalSpace": 0, + "hasAccurateTotalSpace": true + } +] +``` + +| Status | Cause | +| ------ | --------------------------------------------------------------------------------- | +| `200` | Merged mount list, possibly empty | +| `400` | `id` is not an integer | +| `500` | No Radarr settings row with that id, or the disk-space or root-folder read failed | + +Entries that came from the disk-space report pass through unchanged. Entries synthesised from a root folder have `label: null` and `hasAccurateTotalSpace: false`, because the root-folder resource does not report capacity. + +:::caution +Do not use an entry with `hasAccurateTotalSpace: false` for a total-space comparison. Its `totalSpace` is `0`, not the real capacity. Those entries are only meaningful for free space and for the path picker. +::: + +An unreachable Radarr produces a `500` and only a debug-level log line, so at the default log level nothing is recorded about it. + +Both reads are cached for an hour per instance, refreshed in the background. The cache is dropped between rule group runs. The client is only rebuilt when the Radarr setting is saved or deleted, so editing the database directly is not picked up. + +### `GET /api/servarr/sonarr/{id}/diskspace` + +**Return the disk mounts of one configured Sonarr instance, merged with its root folders.** + +Behaves exactly like the Radarr route, with one difference that matters in practice: Sonarr's disk-space report only lists fixed drives. NFS and CIFS media mounts, which are common in Docker setups, only appear through the root-folder supplement, and therefore arrive with `hasAccurateTotalSpace: false`. + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------------------------------ | +| `id` | integer | Yes | Maintainerr's Sonarr settings row id | + +Response shape is identical to the Radarr route. + +| Status | Cause | +| ------ | --------------------------------------------------------------------------------- | +| `200` | Merged mount list, possibly empty | +| `400` | `id` is not an integer | +| `500` | No Sonarr settings row with that id, or the disk-space or root-folder read failed | + +:::caution +The same total-space caveat applies, and it bites harder here: on a NAS-backed Sonarr most or all mounts arrive from the root-folder supplement with `hasAccurateTotalSpace: false`. +::: + +## Quality profiles + +All three profile routes return the same shape and fail open in the same way. + +```json +[{ "id": 1, "name": "HD-1080p" }] +``` + +Only `id` and `name` are contractual. No serializer runs on these routes, so every other field the instance sends is passed through as well, such as `upgradeAllowed`, `cutoff` and `items`. + +### `GET /api/servarr/radarr/{id}/profiles` + +**List the quality profiles of one configured Radarr instance.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------------------------------ | +| `id` | integer | Yes | Maintainerr's Radarr settings row id | + +| Status | Cause | +| ------ | -------------------------------------------------------------------- | +| `200` | Array of profiles, **or an empty array when the Radarr read failed** | +| `400` | `id` is not an integer | +| `500` | No Radarr settings row with that id | + +An empty array means either "no profiles" or "Radarr is unreachable", and the response gives you no way to tell which. Cached for an hour per instance with a background refresh. + +### `GET /api/servarr/sonarr/{id}/profiles` + +**List the quality profiles of one configured Sonarr instance.** + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | ------------------------------------ | +| `id` | integer | Yes | Maintainerr's Sonarr settings row id | + +| Status | Cause | +| ------ | -------------------------------------------------------------------- | +| `200` | Array of profiles, **or an empty array when the Sonarr read failed** | +| `400` | `id` is not an integer | +| `500` | No Sonarr settings row with that id | + +Same fail-open behaviour and same one-hour cache as the Radarr route. + +### `GET /api/servarr/sportarr/{id}/profiles` + +**List the quality profiles of one configured Sportarr instance.** + +Sportarr is reached through its own native API rather than the Sonarr compatibility layer, so this route differs from the other two in a few ways. + +| Parameter | Type | Required | Description | +| --------- | ------- | -------- | -------------------------------------- | +| `id` | integer | Yes | Maintainerr's Sportarr settings row id | + +Response carries an extra field the shared profile shape does not declare: + +```json +[{ "id": 1, "name": "Default", "isDefault": true }] +``` + +| Status | Cause | +| ------ | ---------------------------------------------------------------------- | +| `200` | Array of profiles, **or an empty array when the Sportarr read failed** | +| `400` | `id` is not an integer | +| `500` | No Sportarr settings row with that id | + +It also fails open, but unlike the Radarr and Sonarr profile routes the failure is logged at warning level, so it is visible at the default log level. Results are cached for 20 minutes rather than an hour, and there is no background refresh: the value simply expires. diff --git a/docs/api/settings.md b/docs/api/settings.md new file mode 100644 index 000000000..77e59bf7a --- /dev/null +++ b/docs/api/settings.md @@ -0,0 +1,1236 @@ +--- +slug: /api/settings +title: Settings API +description: Connection settings for every integration, connection tests, and the media server switch. +--- + +Every integration's connection settings, the probes that test them, and the media server switch. This is the largest area of the API and the one that handles the most secrets. + +See [API conventions](../API.md#api-conventions) for the rules that apply across the API. + +## Secrets on this page + +:::danger Per-integration reads return secrets in cleartext +`GET /api/settings` masks nine secret fields. **Every per-integration read on this page does not.** These routes return the real stored value: + +`GET /api/settings/jellyfin`, `GET /api/settings/emby`, `GET /api/settings/seerr`, `GET /api/settings/tautulli`, `GET /api/settings/tracearr`, `GET /api/settings/tmdb`, `GET /api/settings/tvdb`, `GET /api/settings/download-client`, and the three `*arr` list routes. + +`GET /api/settings/database/download` goes further and hands over the entire database, including every secret in plaintext. + +This is deliberate, because the settings forms have to read a value back in order to save it again. But combined with the API having no authentication, it means anyone who can reach the port can read every credential you have stored. See [Security and Authentication](../Security.md). +::: + +:::warning Never post a masked value back +There is **no masked-value detection anywhere** on the write side. If you read a body from `GET /api/settings`, which masks, and post it back, the literal mask string is stored over your real secret. + +The TMDB and TVDB write routes are the exception, since they validate the key before saving, so a mask fails the check and the stored key survives. Everything else stores whatever it is given. + +Read from the per-integration route, not from `GET /api/settings`, when you intend to write the value back. +::: + +## Response shapes + +Most routes here answer with `status`, `code` and `message`: + +```json +{ "status": "OK", "code": 1, "message": "Success" } +``` + +Failures usually arrive as `200` or `201` with `status: "NOK"` and `code: 0`, so check the body rather than the status line. The connection test routes put the discovered version string in `message` on success rather than the word `Success`. + +Successful `POST` requests answer `201`. `PATCH`, `PUT` and `DELETE` answer `200`. + +## Core settings + +### `GET /api/settings` + +**Return the application settings with secret fields masked.** + +This is the backbone read for the whole UI. It returns the full settings row: application title and URL, the Maintainerr API key, the active media server type, every integration's connection fields, both cron schedules, the `*arr` exclusion tag options and the telemetry flag. + +| Status | Cause | +| -------------------------- | -------------------------- | +| `200` | The settings | +| `200` with an empty body | No settings row exists yet | +| `200` with `status: "NOK"` | The database read failed | + +Nine fields are masked: the Plex token, the Jellyfin, Emby, Seerr, TMDB, TVDB, Tautulli and Tracearr API keys, and the download client password. A value of six characters or fewer becomes `****`, anything longer becomes the first three characters, an ellipsis, then the last three. + +:::caution Two secrets are not masked here +`apikey`, the Maintainerr API key itself, and `download_client_username` are returned in the clear. +::: + +This response cannot be safely round-tripped. See the warning above. + +### `POST /api/settings` + +**Merge a partial settings payload over the stored row and re-initialise every dependent client.** + +Every field is optional, and an absent field is left as-is. Accepted fields include `applicationTitle`, `applicationUrl`, `apikey`, `media_server_type`, the Plex connection fields, every integration URL and key, `collection_handler_job_cron`, `rules_handler_job_cron`, the download client options, and the `*arr` exclusion tag options. + +`id` and `telemetryEnabled` are deliberately rejected. Telemetry has its own route. + +| Status | Cause | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `201` | Success, **and every in-band failure below** | +| `400` | Validation failed, such as a URL with no `http://` or `https://` scheme | +| `201` with `status: "NOK"` | `No settings found to update`, `Update failed, invalid CRON value was found`, `Authenticate with Plex before saving Plex server settings.`, or `Failure` | + +Saving re-initialises the media server adapter, Seerr, Tautulli, the download client and the internal client, re-times the collection handler cron job if its schedule changed, and re-times the rules cron job the same way. + +Some normalisation runs on **every** save, even one that touches unrelated fields: the Plex hostname is trimmed, lowercased and stripped of a scheme prefix, `plex_ssl` is forced on for an `https://` hostname or port 443 and off for `http://`, and the Seerr and Tautulli URLs are lowercased. Trailing slashes are stripped from service URLs rather than rejected. + +:::danger This route can change your active media server +`media_server_type` is accepted here, so a client can flip the active media server through this route and **bypass everything `POST /api/settings/media-server/switch` does**: no data is cleared, the old server's credentials are not nulled, and no adapter is torn down. + +You can end up with one server active while another server's credentials and its collections are still in place. Use the switch route. +::: + +Because the whole save is wrapped in one error handler, a failure that happens after the row was written still reports `Failure` even though the write landed. + +### `PATCH /api/settings` + +**Merge a partial settings payload over the stored row.** + +Identical to `POST /api/settings` in body, behaviour and side effects. This is the verb the web UI actually uses. + +| Status | Cause | +| -------------------------- | -------------------------------------- | +| `200` | Success, and every in-band failure | +| `400` | Validation failed | +| `200` with `status: "NOK"` | Same four messages as the `POST` route | + +Unknown keys are stripped rather than rejected. The same media server type trap applies. + +### `GET /api/settings/version` + +**Return the running application version string.** + +The response is a bare string, not JSON. + +| Status | Cause | +| ------ | ------ | +| `200` | Always | + +The value comes only from the environment. A process started without npm reports `0.0.0`, so this is not a reliable build identifier. Use [`GET /api/app/status`](./app-and-health.md#get-apiappstatus) for the richer version payload. + +### `GET /api/settings/api/generate` + +**Generate a fresh Maintainerr API key string without storing it.** + +The response is a bare base64 string. + +| Status | Cause | +| ------ | ------ | +| `200` | Always | + +This does **not** save the key. To store it, send the value back as `apikey` through `PATCH /api/settings`. + +:::caution This does not protect anything +Nothing server-side validates an inbound API key. The key exists so Maintainerr's own internal client can call its own API. Generating a new one does not add authentication, and both this route and the key itself are unauthenticated. +::: + +### `GET /api/settings/test/setup` + +**Report whether the required media server settings are filled in.** + +The response is a bare boolean. + +| Status | Cause | +| ------ | ------------------------------------------------- | +| `200` | Always. Any internal error is reported as `false` | + +"Setup complete" means the fields are populated, **not** that the server is reachable. No connection is attempted. Plex needs a hostname, name, port and token. Jellyfin and Emby need a URL and API key. The user id is optional for both because it can be detected later. + +### `POST /api/settings/cron/validate` + +**Report whether a cron expression parses, without storing it.** + +Request body: + +```json +{ "schedule": "0 0-23/12 * * *" } +``` + +| Status | Cause | +| ------ | ------------------------------------------------------------------------- | +| `201` | For **both** answers. Valid is `status: "OK"`, invalid is `status: "NOK"` | +| `400` | `schedule` is missing or not a string | + +The expression must be exactly 5 fields. A seconds field, month or day names, a `?` blank day, and `7` for Sunday are all rejected. + +### `GET /api/settings/database/download` + +**Stream the live database file as an attachment.** + +| Status | Cause | +| ------ | -------------------------------------------------------------- | +| `200` | The file is streamed | +| `404` | `Database file not found`, meaning it is missing or unreadable | +| `500` | The database is not file-based | + +:::danger This hands over every secret you have stored +The downloaded database contains the Plex token, every `*arr` and integration API key, the download client password and the Maintainerr API key, **all in plaintext**. It completely bypasses the masking on `GET /api/settings`, and like everything else it is unauthenticated. + +Treat the exposure of this one route as equivalent to exposing every credential Maintainerr holds. +::: + +The file is copied as it currently sits on disk, with no locking or checkpointing, so a copy taken mid-write is possible. + +## Media servers + +### `GET /api/settings/plex/devices/servers` + +**List the owned Plex servers on the account, with each connection probed and ranked.** + +Reads the account's server list from plex.tv using the stored token, then makes a live request to **every advertised connection of every owned server** to find which are reachable. Unreachable connections are dropped and the rest are ranked, preferring local direct addresses. + +| Status | Cause | +| ------ | ----------------------------------------------- | +| `200` | An array of servers, **or `[]` on any failure** | + +An empty array conflates "no owned servers" with "plex.tv is down" and "no token stored". There is no error status. + +This is the slow route on this page, because of the per-connection probing. + +### `POST /api/settings/plex/token` + +**Store a Plex auth token obtained from the sign-in flow.** + +Request body: + +```json +{ "plex_auth_token": "..." } +``` + +| Status | Cause | +| -------------------------- | ------------------------------------------------------- | +| `201` | Stored | +| `400` | The token is missing or blank | +| `201` with `status: "NOK"` | The write failed. The message is the bare word `Failed` | + +The token is stored as given and is **not** verified against plex.tv here. Use `GET /api/settings/test/plex/auth` to check it. + +### `DELETE /api/settings/plex/auth` + +**Clear the stored Plex auth token and tear down the Plex clients.** + +| Status | Cause | +| -------------------------- | --------------------------------------------- | +| `200` | Cleared | +| `200` with `status: "NOK"` | The write failed, or there is no settings row | + +:::warning Destructive +Deletes the stored Plex token. **Maintainerr keeps no copy**, so recovering means signing in to Plex again. + +Only the token is removed. The hostname, port, SSL flag, server name and machine id all stay, so your server selection survives and comes back once a new token is stored. + +No collections, rules or exclusions are touched. +::: + +Afterwards, the media server becomes unavailable to Maintainerr until a new token is saved. + +### `GET /api/settings/test/plex` + +**Probe the configured Plex server and return its version.** + +| Status | Cause | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `200` with `status: "OK"` | Connected. `message` is the Plex version | +| `200` with `status: "NOK"` | `Authenticate with Plex before testing the connection.` when no token is stored, otherwise the bare word `Failure` | + +This never returns an HTTP error. Note that a `Failure` conflates an unreachable server with a client that was never started, so it gives you little to diagnose with. + +It tests whatever connection the process currently holds, which is not necessarily the hostname you most recently saved. Save first, then test. + +### `GET /api/settings/test/plex/auth` + +**Validate the stored Plex token against plex.tv.** + +| Status | Cause | +| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `200` with `status: "OK"` | The token is valid | +| `200` with `status: "NOK"` | `Authenticate with Plex before validating the connection.`, or `Stored Plex credentials are invalid. Re-authenticate with Plex.` | +| `200` with `status: "NOK"` and `unreachable: true` | plex.tv could not be reached. **Your saved token is still in use** | + +The three-way answer is the point of this route. Only a genuine rejection from plex.tv means the token is bad. Anything else, including a rate limit or a server error, sets `unreachable` so a transient outage is not mistaken for an invalid token. + +Nothing is written here, so an invalid token stays stored until you delete it. + +### `GET /api/settings/jellyfin` + +**Return the stored Jellyfin URL, API key and user id.** + +| Status | Cause | +| -------------------------- | ----------------------------------------------- | +| `200` | The settings, **with the API key in cleartext** | +| `200` with `status: "NOK"` | The read failed | + +An unconfigured install returns nulls despite the declared type. `jellyfin_server_name` is stored but not returned here. + +### `POST /api/settings/jellyfin` + +**Test, then store Jellyfin credentials and make Jellyfin the active media server.** + +Request body: + +```json +{ + "jellyfin_url": "http://jellyfin:8096", + "jellyfin_api_key": "...", + "jellyfin_user_id": "" +} +``` + +`jellyfin_user_id` is optional. An empty value triggers automatic detection of an admin user. + +| Status | Cause | +| -------------------------- | ------------------------------------------------------------------------------ | +| `201` | Saved | +| `201` with `status: "NOK"` | The connection test failed, the chosen user is not an admin, or the save threw | +| `400` | Validation failed | + +The save is gated on a live connection test, so unreachable credentials cannot be stored. + +:::warning This changes your active media server +Saving here flips the active media server to Jellyfin **without going through the switch route**. Plex and Emby credentials are not cleared, and no collections, rules or exclusions are touched. + +You can end up with Jellyfin active alongside stale credentials and collections shaped for another server. Use `POST /api/settings/media-server/switch` for a real switch. +::: + +Saving also re-initialises Streamystats, because it authenticates with the Jellyfin API key. + +The connection probe sets no timeout, so this can hang for a while against an unresponsive host. + +### `POST /api/settings/jellyfin/test` + +**Probe a Jellyfin server with supplied credentials.** + +Takes the same body as the save route. Nothing is stored. + +| Status | Cause | +| -------------------------- | -------------------------------------------------------------------------------- | +| `201` with `status: "OK"` | Connected. `message` names the server, and `users` lists **administrators only** | +| `201` with `status: "NOK"` | Any failure | +| `400` | Validation failed | + +Testing is effectively a prerequisite for choosing a user, because the save route validates the user id against this same admin-only list. + +Note the message `Invalid API key` is returned for **any** error on the user lookup, not just an authentication failure. + +### `DELETE /api/settings/jellyfin` + +**Clear the stored Jellyfin credentials.** + +| Status | Cause | +| -------------------------- | ---------------- | +| `200` | Cleared | +| `200` with `status: "NOK"` | The write failed | + +:::warning Destructive, and it cascades +Clears the Jellyfin URL, API key, user id and server name. **It also clears your Streamystats URL**, because Streamystats authenticates with the Jellyfin key and would otherwise be left half configured. Nothing in the response tells you this happened. + +Maintainerr keeps no copy of any of it. + +It does **not** reset the active media server type, so the install is left reporting Jellyfin as active with no credentials behind it. Collections, membership, exclusions and rule groups are untouched. +::: + +### `GET /api/settings/emby` + +**Return the stored Emby URL, API key and user id.** + +| Status | Cause | +| -------------------------- | ----------------------------------------------- | +| `200` | The settings, **with the API key in cleartext** | +| `200` with `status: "NOK"` | The read failed | + +If the login flow was used, the stored API key is a live Emby access token. + +### `POST /api/settings/emby` + +**Test, then store Emby credentials and make Emby the active media server.** + +Takes `emby_url`, `emby_api_key` and an optional `emby_user_id`. + +| Status | Cause | +| -------------------------- | ------------------------------------------------------------------------------ | +| `201` | Saved | +| `201` with `status: "NOK"` | The connection test failed, the chosen user is not an admin, or the save threw | +| `400` | Validation failed | + +:::warning This changes your active media server +The same trap as the Jellyfin save. It flips the active media server to Emby without clearing anything. Use the switch route for a real switch. +::: + +Unlike Jellyfin, there is no automatic admin detection. Leaving `emby_user_id` empty stores no user, and an administrator is resolved lazily when first needed. + +:::caution A user picked after login can be rejected here +The admin check compares against the **admin-only** list from `POST /api/settings/emby/test`, while `POST /api/settings/emby/login` returns **every** user unfiltered. Choosing a non-admin from the login response fails at save time with `Selected Emby user must be an admin.` +::: + +### `POST /api/settings/emby/test` + +**Probe an Emby server with an API key.** + +Takes the same body as the save route. Nothing is stored. + +| Status | Cause | +| -------------------------- | ------------------------------------------------ | +| `201` with `status: "OK"` | Connected. `users` lists **administrators only** | +| `201` with `status: "NOK"` | Any failure | +| `400` | Validation failed | + +### `POST /api/settings/emby/login` + +**Authenticate against an Emby server with an admin username and password.** + +Request body: + +```json +{ "emby_url": "http://emby:8096", "username": "admin", "password": "..." } +``` + +On success the response carries `token`, `userId`, `serverName`, `users` and `libraries`. + +| Status | Cause | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `201` with `status: "OK"` | Authenticated | +| `201` with `status: "NOK"` | `Invalid Emby username or password`, `User authenticated but is not an administrator on this Emby server`, or a connection failure | +| `400` | Validation failed | + +Nothing is stored in Maintainerr. To keep the token, send it to `POST /api/settings/emby` as `emby_api_key`. + +:::danger Cleartext credentials in both directions +The request carries an admin password in the clear, over whatever scheme you supply, and `http://` is accepted. The response hands back a **live Emby access token** in the clear. + +There is no authentication in front of this route, so anyone who can reach the Maintainerr API can use it to test credentials against, or relay them to, any Emby server they can reach from your host. +::: + +This does have a real effect on the Emby side: it mints a genuine access token and registers a device session that shows up in Emby's active devices list. Clearing the key in Maintainerr does not revoke that session, which must be removed in Emby. + +`users` here is unfiltered, unlike the test route. + +### `DELETE /api/settings/emby` + +**Clear the stored Emby credentials.** + +| Status | Cause | +| -------------------------- | ---------------- | +| `200` | Cleared | +| `200` with `status: "NOK"` | The write failed | + +:::warning Destructive +Clears the Emby URL, API key, user id and server name, with no copy kept. + +Unlike the Jellyfin delete this does **not** cascade to any other integration, because Streamystats is Jellyfin only. + +It does **not** reset the active media server type, so the install is left reporting Emby as active with no credentials behind it. Collections, membership, exclusions and rule groups are untouched. +::: + +If the stored key was an access token minted by the login route, clearing it here does **not** revoke the session on the Emby server. Remove that from Emby's device list separately. + +### `GET /api/settings/media-server/switch/preview/{targetServerType}` + +**Report what a switch would clear, keep and migrate.** + +| Parameter | Type | Required | Description | +| ------------------ | ---- | -------- | ---------------------------- | +| `targetServerType` | path | Yes | `plex`, `jellyfin` or `emby` | + +The response carries `currentServerType`, `targetServerType`, `dataToBeCleared` with counts, `dataToBeKept`, and `ruleMigration` with counts and a per-rule list of what could not carry over. + +| Status | Cause | +| ------ | --------------------------------------- | +| `200` | The preview | +| `400` | The target type is not one of the three | +| `500` | The analysis threw | + +Nothing is written and no media server is contacted. + +:::caution The counts ignore rule migration +`dataToBeCleared.collections` counts collections as cleared even when you intend to switch with migration on, in which case they are reset rather than deleted. + +`ruleMigration` is omitted entirely on a fresh install with no current server. +::: + +### `POST /api/settings/media-server/switch` + +**Switch the active media server type, wiping media-server-specific data and the old server's credentials.** + +Request body: + +```json +{ "targetServerType": "jellyfin", "migrateRules": true } +``` + +| Status | Cause | +| -------------------------- | -------------------------------------------------------------------------------------------------------- | +| `201` | Switched. The body carries `clearedData` and, with migration, `ruleMigration` | +| `201` with `status: "NOK"` | `Already using as media server`, so nothing was cleared, or the switch failed and was rolled back | +| `400` | Validation failed | +| `409` | `A media server switch is already in progress` | + +:::danger Destructive: this is the most far-reaching route in the API +In one transaction it **permanently deletes all collection membership, every collection log and every exclusion**. + +Without `migrateRules` it also **deletes every rule group, its rules, and every collection**, and then deletes each collection's stored poster from disk. + +With `migrateRules` it instead keeps rule groups and collections, rewriting rules for the target server, but **deactivates every rule group and clears its library**, so nothing runs until you reassign libraries. + +It then clears the departing server's credentials: leaving Plex nulls the Plex fields **and your Tautulli URL and API key**; leaving Jellyfin nulls the Jellyfin fields **and your Streamystats URL**; leaving Emby nulls the Emby fields. The Tracearr server binding is cleared in every case. + +**None of this can be undone.** Take a backup with `GET /api/settings/database/download` first. No media files are deleted and nothing leaves your library. +::: + +While a switch is running, media server routes answer `503`. + +:::caution Even a rejected same-type switch opens the switch window +The "already using" check runs after the switch has been marked as in progress, so a rejected request still briefly makes media server routes answer `503`. + +On a fresh install with no current type the check is skipped and the clearing path still runs. That is harmless on an empty database, but the initial setup click goes through the same code. +::: + +Note the alternative way in: `POST` and `PATCH /api/settings` accept `media_server_type` directly and do **none** of this. + +## Media managers + +Radarr, Sonarr and Sportarr are configured as lists of instances, and the four routes for each behave identically. What follows applies to all three. + +**The list route** returns every configured instance as `id`, `serverName`, `url` and `apiKey`, with the **API key in cleartext**. It answers `200` even on failure, in which case the body is an error envelope object rather than an array, so check the shape before iterating. + +**The create and update routes** take `serverName`, `url` and `apiKey`, all required. The URL is **forced to lowercase** when stored, which breaks an instance behind a case-sensitive reverse proxy path. Credentials are stored verbatim and unverified, so test first if you want verification. Duplicate names and URLs are allowed. + +**The update route** is a full replace, not a partial update, and the id in the path wins over any id in the body. It has no existence check: updating an id that does not exist **creates a row with that id** rather than failing. + +**The delete route** refuses while any collection still references the instance, and returns the offending collections so you can see which. Deleting an id that does not exist reports a generic failure rather than a `404`. + +The `{id}` in these paths is Maintainerr's own settings row id. + +### `GET /api/settings/radarr` + +**List every configured Radarr instance.** + +| Status | Cause | +| ------ | -------------------------------------------------------------------------- | +| `200` | The instances, **with API keys in cleartext**, or an error envelope object | + +### `POST /api/settings/radarr` + +**Create a new Radarr instance.** + +| Status | Cause | +| -------------------------- | --------------------------------------------------------------------- | +| `201` | Created. The body carries the new instance including its generated id | +| `201` with `status: "NOK"` | The write failed | +| `400` | Validation failed | + +### `PUT /api/settings/radarr/{id}` + +**Overwrite one Radarr instance's name, URL and API key.** + +| Status | Cause | +| -------------------------- | -------------------------------------------- | +| `200` | Updated | +| `200` with `status: "NOK"` | The write failed | +| `400` | `id` is not an integer, or validation failed | + +The cached client for that instance is rebuilt so later runs use the new details. + +### `DELETE /api/settings/radarr/{id}` + +**Delete a Radarr instance, refusing while collections still reference it.** + +| Status | Cause | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `200` with `status: "OK"` | Deleted | +| `200` with `status: "NOK"` | Blocked with `Cannot delete setting with associated collections`, and `data.collectionsInUse` lists them. Or a generic `Failure`, which is also what an unknown id returns | +| `400` | `id` is not an integer | + +:::warning Destructive +Permanently deletes the instance row, including its API key. **Maintainerr keeps no copy.** + +Nothing cascades: the in-use check is what stops a collection being left pointing at a missing instance. Nothing is deleted in Radarr itself. +::: + +### `GET /api/settings/sonarr` + +**List every configured Sonarr instance.** + +| Status | Cause | +| ------ | -------------------------------------------------------------------------- | +| `200` | The instances, **with API keys in cleartext**, or an error envelope object | + +### `POST /api/settings/sonarr` + +**Create a new Sonarr instance.** + +| Status | Cause | +| -------------------------- | ----------------- | +| `201` | Created | +| `201` with `status: "NOK"` | The write failed | +| `400` | Validation failed | + +### `PUT /api/settings/sonarr/{id}` + +**Overwrite one Sonarr instance's name, URL and API key.** + +| Status | Cause | +| -------------------------- | -------------------------------------------- | +| `200` | Updated | +| `200` with `status: "NOK"` | The write failed | +| `400` | `id` is not an integer, or validation failed | + +### `DELETE /api/settings/sonarr/{id}` + +**Delete a Sonarr instance, refusing while collections still reference it.** + +| Status | Cause | +| -------------------------- | --------------------------------------------------------- | +| `200` with `status: "OK"` | Deleted | +| `200` with `status: "NOK"` | Blocked with the collections listed, or a generic failure | +| `400` | `id` is not an integer | + +:::warning Destructive +Permanently deletes the instance row, including its API key, with no copy kept. Nothing is deleted in Sonarr itself. +::: + +### `GET /api/settings/sportarr` + +**List every configured Sportarr instance.** + +| Status | Cause | +| ------ | -------------------------------------------------------------------------- | +| `200` | The instances, **with API keys in cleartext**, or an error envelope object | + +### `POST /api/settings/sportarr` + +**Create a new Sportarr instance.** + +| Status | Cause | +| -------------------------- | ----------------- | +| `201` | Created | +| `201` with `status: "NOK"` | The write failed | +| `400` | Validation failed | + +:::caution The version requirement is not enforced here +Only `POST /api/settings/test/sportarr` checks the minimum supported Sportarr version. Saving without testing stores an unsupported instance happily. +::: + +### `PUT /api/settings/sportarr/{id}` + +**Overwrite one Sportarr instance's name, URL and API key.** + +| Status | Cause | +| -------------------------- | -------------------------------------------- | +| `200` | Updated | +| `200` with `status: "NOK"` | The write failed | +| `400` | `id` is not an integer, or validation failed | + +### `DELETE /api/settings/sportarr/{id}` + +**Delete a Sportarr instance, refusing while collections still reference it.** + +| Status | Cause | +| -------------------------- | --------------------------------------------------------- | +| `200` with `status: "OK"` | Deleted | +| `200` with `status: "NOK"` | Blocked with the collections listed, or a generic failure | +| `400` | `id` is not an integer | + +:::warning Destructive +Permanently deletes the instance row, including its API key, with no copy kept. Nothing is deleted in Sportarr itself. +::: + +### `POST /api/settings/test/radarr` + +**Probe a Radarr connection using credentials in the body, without saving.** + +Takes the same body as the save route, including `serverName`, which the probe does not use. + +| Status | Cause | +| -------------------------- | ------------------------------------------------------------------------------------------------- | +| `201` with `status: "OK"` | Connected. `message` is the Radarr version | +| `201` with `status: "NOK"` | `Failure`, or `Unexpected application name returned: ` when a different application answers | +| `400` | Validation failed | + +Real connection failures come back as the bare word `Failure` with no detail, because the underlying error is swallowed before it can be classified. + +Testing is not a precondition for saving. + +### `POST /api/settings/test/sonarr` + +**Probe a Sonarr connection using credentials in the body, without saving.** + +| Status | Cause | +| -------------------------- | -------------------------------------------- | +| `201` with `status: "OK"` | Connected. `message` is the Sonarr version | +| `201` with `status: "NOK"` | `Failure`, or an unexpected application name | +| `400` | Validation failed | + +A Radarr behind the URL is rejected by name. + +### `POST /api/settings/test/sportarr` + +**Probe a Sportarr connection and enforce the minimum supported version, without saving.** + +| Status | Cause | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `201` with `status: "OK"` | Connected. `message` is the Sportarr version | +| `201` with `status: "NOK"` | `Failure`, an unexpected application name, or `Sportarr is below the minimum supported version 4.0.1022. Please update Sportarr.` | +| `400` | Validation failed | + +This is the only place the version requirement is applied. A build whose version cannot be parsed passes the check. + +## Requests + +The three Seerr settings routes each answer on `/api/settings/seerr`, `/api/settings/overseerr` and `/api/settings/jellyseerr`. They are aliases of one handler with one storage location, and `/api/settings/seerr` is canonical. + +### `GET /api/settings/seerr` + +**Return the stored Seerr URL and API key.** + +| Status | Cause | +| -------------------------- | ----------------------------------------------- | +| `200` | The settings, **with the API key in cleartext** | +| `200` with `status: "NOK"` | The read failed | + +### `POST /api/settings/seerr` + +**Store the Seerr URL and API key.** + +Request body needs both `url` and `api_key`. + +| Status | Cause | +| -------------------------- | ----------------- | +| `201` | Saved | +| `201` with `status: "NOK"` | The write failed | +| `400` | Validation failed | + +The connection is stored **unverified**. Use `POST /api/settings/test/seerr` first if you want verification. + +Both fields are required together, so you cannot clear Seerr here. Use the `DELETE` route. + +### `DELETE /api/settings/seerr` + +**Clear the stored Seerr settings.** + +| Status | Cause | +| -------------------------- | ---------------- | +| `200` | Cleared | +| `200` with `status: "NOK"` | The write failed | + +:::warning Destructive +Clears the Seerr URL and API key, with no copy kept. The Seerr client is dropped, so nothing queries Seerr afterwards. + +Rules that read Seerr values are not deleted or rewritten. They simply stop resolving. +::: + +### `POST /api/settings/test/seerr` + +**Probe a Seerr instance with supplied credentials, without saving.** + +Also answers on the `/api/settings/test/overseerr` and `/api/settings/test/jellyseerr` aliases. + +| Status | Cause | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `201` with `status: "OK"` | Connected. `message` is the Seerr version | +| `201` with `status: "NOK"` | `Failure, an unexpected response was returned. The URL is likely incorrect.`, or a classified connection failure such as `Invalid API key` | +| `400` | Validation failed | + +There is no "test what is currently stored" mode. Both fields are required in the body. + +:::caution This sends your credentials to whatever host you name +The URL and key both come from the request body, so this route makes the server contact any address you supply. Private addresses are deliberately allowed, since self-hosted services need them. Combined with the lack of authentication, that makes this a way to have your server issue requests on someone else's behalf. +::: + +## Watch statistics + +### `GET /api/settings/tautulli` + +**Return the stored Tautulli URL and API key.** + +| Status | Cause | +| -------------------------- | ----------------------------------------------- | +| `200` | The settings, **with the API key in cleartext** | +| `200` with `status: "NOK"` | The read failed | + +### `POST /api/settings/tautulli` + +**Store the Tautulli URL and API key.** + +Both `url` and `api_key` are required. + +| Status | Cause | +| -------------------------- | ----------------- | +| `201` | Saved | +| `201` with `status: "NOK"` | The write failed | +| `400` | Validation failed | + +Stored unverified. Test first if you want verification. + +### `DELETE /api/settings/tautulli` + +**Clear the stored Tautulli connection.** + +| Status | Cause | +| -------------------------- | ---------------- | +| `200` | Cleared | +| `200` with `status: "NOK"` | The write failed | + +:::warning Destructive +Clears the Tautulli URL and API key, with no copy kept, and drops the client so nothing queries Tautulli afterwards. + +Rules that read Tautulli values are not deleted or rewritten. They stop resolving, which for a watch-count rule means no value rather than zero. + +There is no confirmation step: one unauthenticated request wipes the integration. +::: + +### `POST /api/settings/test/tautulli` + +**Probe a Tautulli URL and API key, without saving.** + +| Status | Cause | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `201` with `status: "OK"` | Connected. `message` is the Tautulli version | +| `201` with `status: "NOK"` | Tautulli's own error message, `Failure, an unexpected response was returned. The URL is likely incorrect.`, or a classified connection failure | +| `400` | Validation failed | + +The same caveat as the Seerr test applies: the credentials in the body are sent to the host in the body. + +### `GET /api/settings/streamystats` + +**Return the stored Streamystats URL.** + +Streamystats has no key of its own. It reuses the Jellyfin API key. + +| Status | Cause | +| -------------------------- | -------------------------------------------------------------------------- | +| `200` | The URL | +| `200` with `status: "NOK"` | The read failed | +| `403` | `Streamystats is only available when Jellyfin is the active media server.` | + +Note the ordering: the settings read happens **before** the Jellyfin check, so on Plex or Emby with a broken database you get the `200` envelope rather than the `403`. + +### `POST /api/settings/streamystats` + +**Store the Streamystats URL.** + +Request body is `{ "url": "..." }` only. + +| Status | Cause | +| -------------------------- | --------------------------------------- | +| `201` | Saved | +| `201` with `status: "NOK"` | The write failed | +| `400` | Validation failed | +| `403` | The active media server is not Jellyfin | + +The URL is stored without any probe. + +:::caution Saving without a Jellyfin key leaves the integration inert +The client is only built when both the Streamystats URL **and** a Jellyfin API key are present. Save a URL with no Jellyfin key and the setting persists while nothing works. +::: + +### `DELETE /api/settings/streamystats` + +**Clear the stored Streamystats URL.** + +| Status | Cause | +| -------------------------- | ----------------------------------------------------------------- | +| `200` | Cleared | +| `200` with `status: "NOK"` | The write failed | +| `403` | The active media server is not Jellyfin, checked before any write | + +:::warning Destructive +Clears the Streamystats URL, with no copy kept, and drops the client. Only the URL is affected, and your Jellyfin key is untouched. + +Rules that read Streamystats values stop resolving rather than being removed. +::: + +### `POST /api/settings/test/streamystats` + +**Probe a Streamystats URL, without saving or sending any credential.** + +| Status | Cause | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `201` with `status: "OK"` | Connected. `message` is the Streamystats version | +| `201` with `status: "NOK"` | `Unexpected response from Streamystats. Verify the URL points to a Streamystats instance.` or a connection failure | +| `400` | Validation failed | +| `403` | The active media server is not Jellyfin | + +This is the only test route that deliberately **withholds** a stored credential from the URL you supply, so it cannot be used to leak your Jellyfin key to an arbitrary host. + +A pass here does not prove the integration will work, because the live client also needs the Jellyfin API key, which this probe never exercises. + +### `GET /api/settings/tracearr` + +**Return the stored Tracearr URL, API key and bound server id.** + +| Status | Cause | +| -------------------------- | ----------------------------------------------- | +| `200` | The settings, **with the API key in cleartext** | +| `200` with `status: "NOK"` | The read failed | + +### `POST /api/settings/tracearr` + +**Store the Tracearr connection, resolving and verifying the bound server before saving.** + +Request body takes `url`, `api_key` and an optional `server_id`, which must be a UUID. + +| Status | Cause | +| -------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `201` | Saved | +| `201` with `status: "NOK"` | No matching Tracearr server was found, the chosen server tracks a different media server, or something threw | +| `400` | Validation failed | + +This is the only write on this page that contacts external services before saving. Leave `server_id` out and Maintainerr resolves the Tracearr server that tracks your media server. Send it only when Tracearr has several servers of that type, in which case no resolution happens and the id is used as given. + +The save is refused if no server matches, or if the one you named tracks a different media server. An unreadable library check does **not** block the save, so a transient failure cannot lock you out. + +This can be slow: resolution probes up to 20 items per candidate server against your media server. + +:::caution The version requirement is not enforced here +Only `POST /api/settings/test/tracearr` checks the minimum supported Tracearr version. +::: + +### `DELETE /api/settings/tracearr` + +**Clear the stored Tracearr connection and flush its cached history.** + +| Status | Cause | +| -------------------------- | ---------------- | +| `200` | Cleared | +| `200` with `status: "NOK"` | The write failed | + +:::warning Destructive +Clears the Tracearr URL, API key **and the resolved server binding**, with no copy kept, then drops the client and wipes the cached history index. + +Re-adding Tracearr re-runs the whole resolve-and-verify probe. Rules that read Tracearr history are not deleted, they stop resolving. +::: + +### `POST /api/settings/tracearr/servers` + +**List the Tracearr servers that match your media server, for the settings picker.** + +Request body takes `url` and `api_key`. + +| Status | Cause | +| ------ | -------------------------------------------------------------------------------- | +| `201` | An array of servers with `id` and `name`. **May legitimately be empty** | +| `400` | Validation failed | +| `502` | The document fetch threw, or reading item metadata from your media server failed | + +An empty array is ambiguous: it can mean the document had no server list, or the media server type filter removed every candidate. If the library list cannot be read, every candidate is returned with a warning rather than an error. + +### `POST /api/settings/test/tracearr` + +**Probe a Tracearr URL and API key and check its version, without saving.** + +| Status | Cause | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `201` with `status: "OK"` | Connected. `message` is the Tracearr version | +| `201` with `status: "NOK"` | `Unexpected response from Tracearr. Verify the URL points to a Tracearr v2 instance.`, a below-minimum-version refusal, or a connection failure | +| `400` | Validation failed | + +`server_id` is accepted by the schema but unused. A pass here says nothing about whether the save will find a matching server. + +## Metadata providers + +### `GET /api/settings/metadata-provider` + +**Return which metadata provider is configured as primary.** + +Response is `{ "preference": "tmdb_primary" }` or `tvdb_primary`. + +| Status | Cause | +| ------ | ---------------------------------------------------------------------------------------------------- | +| `200` | Always. A database failure is reported as `tmdb_primary` and is indistinguishable from a real answer | + +### `POST /api/settings/metadata-provider` + +**Store which metadata provider should be primary.** + +Request body is `{ "preference": "tmdb_primary" }` or `tvdb_primary`. + +| Status | Cause | +| -------------------------- | ------------------------------------------------------- | +| `201` | Saved | +| `201` with `status: "NOK"` | The write failed | +| `400` | The value is missing or outside the two allowed options | + +:::caution The server does not check that TVDB is configured +`tvdb_primary` is accepted with no TVDB key stored. That guard exists only in the web UI. The unavailable provider is then filtered out at lookup time, so TMDB is used anyway while the stored value says otherwise. + +Deleting the TVDB key later does not reset this value either. +::: + +### `GET /api/settings/tmdb` + +**Return the stored TMDB API key.** + +| Status | Cause | +| -------------------------- | ---------------------------------------------------------------------------------------------- | +| `200` | `{ "api_key": "..." }`, **in cleartext**. A never-configured key reads back as an empty string | +| `200` with `status: "NOK"` | The read failed | + +### `POST /api/settings/tmdb` + +**Validate a TMDB API key and store it if it works.** + +| Status | Cause | +| -------------------------- | ----------------------------------------------- | +| `201` with `status: "OK"` | Stored | +| `201` with `status: "NOK"` | The key was rejected, usually `Invalid API key` | +| `400` | The field is missing or not a string | + +The key is checked against TMDB **before** anything is written, so a wrong key cannot overwrite a working one. + +:::caution Posting an empty key silently reverts to the bundled key +An empty string passes validation, and the check then falls back to the currently loaded key, which normally passes. The empty string is stored, `Success` is reported, and the running client drops back to the shared key that ships with Maintainerr. + +The effect is the same as calling `DELETE`. Use `DELETE` if that is what you want. +::: + +### `DELETE /api/settings/tmdb` + +**Clear the stored TMDB API key and fall back to the built-in shared key.** + +| Status | Cause | +| -------------------------- | ---------------- | +| `200` | Cleared | +| `200` with `status: "NOK"` | The write failed | + +:::warning Destructive +Clears your TMDB API key, with no copy kept. + +TMDB is never fully removed. It reverts to the shared key bundled with Maintainerr, so metadata lookups keep working. Your metadata provider preference is untouched. + +The cached TMDB responses are not flushed. Use `POST /api/settings/metadata/refresh/tmdb` for that. +::: + +### `GET /api/settings/tvdb` + +**Return the stored TVDB API key.** + +| Status | Cause | +| -------------------------- | ---------------------------------------------------------------------------------------------- | +| `200` | `{ "api_key": "..." }`, **in cleartext**. A never-configured key reads back as an empty string | +| `200` with `status: "NOK"` | The read failed | + +### `POST /api/settings/tvdb` + +**Validate a TVDB API key and store it if it works.** + +| Status | Cause | +| -------------------------- | ----------------------------------------------------------------------------------------------- | +| `201` with `status: "OK"` | Stored | +| `201` with `status: "NOK"` | `Invalid API key`, `No TVDB API key configured`, `Unexpected response`, or a connection failure | +| `400` | The field is missing or not a string | + +Validated before saving, so a bad key never overwrites a good one. + +:::danger Posting an empty key wipes your configured key +This is worse than the TMDB case. An empty string passes validation, the check falls back to the **already stored** key and passes, and the empty string is then stored. + +`Success` is reported while your TVDB key is gone and TVDB is left unauthenticated, exactly as if you had called `DELETE`. Unlike TMDB there is no bundled fallback key. Use `DELETE` if that is what you want. +::: + +### `DELETE /api/settings/tvdb` + +**Clear the stored TVDB API key and drop the session.** + +| Status | Cause | +| -------------------------- | ---------------- | +| `200` | Cleared | +| `200` with `status: "NOK"` | The write failed | + +:::warning Destructive +Clears your TVDB API key, with no copy kept, and discards the session token. **Unlike TMDB there is no bundled fallback**, so the TVDB provider becomes unavailable entirely. + +Your metadata provider preference is **not** reset. An install left on `tvdb_primary` keeps that value with no usable provider behind it. + +Cached TVDB responses are not flushed. +::: + +### `POST /api/settings/test/tmdb` + +**Test a TMDB API key, without saving it.** + +| Status | Cause | +| -------------------------- | ----------------------------------------------------------------------------------------------- | +| `201` with `status: "OK"` | The key works | +| `201` with `status: "NOK"` | `Invalid API key`, `No TMDB API key configured`, `Unexpected response`, or a connection failure | +| `400` | The field is missing or not a string | + +An empty key re-tests whatever is currently loaded rather than reporting that nothing is configured. + +### `POST /api/settings/test/tvdb` + +**Test a TVDB API key, without saving it.** + +| Status | Cause | +| -------------------------- | ----------------------------------------------------------------------------------------------- | +| `201` with `status: "OK"` | The key works | +| `201` with `status: "NOK"` | `Invalid API key`, `No TVDB API key configured`, `Unexpected response`, or a connection failure | +| `400` | The field is missing or not a string | + +An empty key re-tests the already stored key, and only reports that nothing is configured when nothing is stored either. + +### `POST /api/settings/metadata/refresh/{provider}` + +**Flush one provider's cache and re-queue a metadata refresh for every affected item.** + +| Parameter | Type | Required | Description | +| ---------- | ---- | -------- | ---------------------------- | +| `provider` | path | Yes | `tmdb`, `tvdb` or `sportarr` | + +No request body is read. + +| Status | Cause | +| -------------------------- | ---------------------------------------------------------------------------------------------- | +| `201` with `status: "OK"` | ` metadata refresh started`, or ` metadata refresh is already in progress` | +| `201` with `status: "NOK"` | The provider's connection test failed, or something threw | +| `400` | The provider is not one of the three | + +The connection is tested first using whatever is **already configured**, not anything from the request. TVDB reports `No TVDB API key configured` and does nothing when no key is stored. + +The refresh itself is fire and forget. Only its start is reported, and per-item failures are logged rather than returned. + +:::caution "Started" does not always mean started +One lock is shared across all three providers, so a request during another provider's run answers `code: 1` with the **already in progress** message. Only the message text tells you which happened. + +If no media server is configured the sweep returns immediately and silently, even though the response already said it started. +::: + +## Download client + +### `GET /api/settings/download-client` + +**Return the stored download client connection and cleanup options.** + +| Status | Cause | +| -------------------------- | ------------------------------------------------ | +| `200` | The settings, **with the password in cleartext** | +| `200` with `status: "NOK"` | The read failed | + +An unconfigured client reads as empty strings with `download_client_delete_data` true and `download_client_fallback_ratio` `0.5`, which is indistinguishable from a deliberately blank configuration. + +### `POST /api/settings/download-client` + +**Store the download client connection and cleanup options.** + +Request body: + +```json +{ + "download_client_url": "http://qbittorrent:8080", + "download_client_username": "user", + "download_client_password": "...", + "download_client_delete_data": true, + "download_client_fallback_ratio": 0.5 +} +``` + +`download_client_delete_data` and `download_client_fallback_ratio` are **required**, so a partial update is not possible. To change only the URL you must send the other fields too. + +| Status | Cause | +| -------------------------- | ------------------------------------------------ | +| `201` | Saved | +| `201` with `status: "NOK"` | The write failed | +| `400` | Validation failed, including a ratio below `0.5` | + +The username and password are deliberately **not** trimmed, so a credential with real leading or trailing whitespace survives. An empty username and password are a valid configuration, since qBittorrent can bypass authentication for whitelisted subnets. + +The connection is stored unverified. + +:::caution download_client_delete_data has real destructive reach +With it on, removing a download also **deletes its data on disk** during cleanup, though data shared with another download is kept. This takes effect the moment you save, changing what the file-deleting `*arr` actions do. +::: + +### `DELETE /api/settings/download-client` + +**Clear the download client configuration and reset its cleanup options.** + +| Status | Cause | +| -------------------------- | ---------------- | +| `200` | Cleared | +| `200` with `status: "NOK"` | The write failed | + +:::warning Destructive, and it resets more than the connection +Clears the URL, username and password, with no copy kept, **and resets `download_client_delete_data` to true and `download_client_fallback_ratio` to 0.5**. + +That reset is the non-obvious part: if you had turned data deletion off, removing and re-adding the client silently gives you the on-by-default behaviour back. + +No torrents are touched. Afterwards the file-deleting `*arr` actions stop attempting download cleanup entirely. +::: + +### `POST /api/settings/test/download-client` + +**Probe a download client URL and credentials, without saving.** + +| Status | Cause | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `201` with `status: "OK"` | Connected. `message` is the qBittorrent version | +| `201` with `status: "NOK"` | `Invalid username or password`, a message about the Web UI IP whitelist on a `403`, `Unexpected response from the download client. Verify the URL points to a qBittorrent WebUI.`, or a connection failure | +| `400` | Validation failed | + +:::caution The body is the whole settings schema +`download_client_delete_data` and `download_client_fallback_ratio` are required even for a pure connection test. Omitting them is a `400`. +::: + +This logs in to the instance you name, which creates a session there. + +## Telemetry + +### `GET /api/settings/telemetry/status` + +**Report whether telemetry is forced off and when the next reports are due.** + +Response: + +```json +{ + "forcedOff": false, + "nextSendAtWeekly": "2026-06-08T04:12:00.000Z", + "nextSendAtRich": "2026-09-14T04:12:00.000Z" +} +``` + +| Status | Cause | +| ------ | ------ | +| `200` | Always | + +Both dates are `null` whenever reporting is off, either because `TELEMETRY=off` is set in the environment or because the setting is off. They can also both be null while telemetry is on, if the scheduled job failed to register. + +The weekly slot is derived per install so instances do not all report at the same minute. + +### `GET /api/settings/telemetry/preview` + +**Build and return the exact telemetry ping this server would send.** + +| Status | Cause | +| ------ | ---------------------- | +| `200` | The payload | +| `500` | A database read failed | + +The payload carries the version, version tag, whether it is running in Docker, the Node major version, architecture, platform, and which media server type is configured. It always includes the `sample` block, deliberately, so you can review everything that could ever be sent. A real ping only carries that block one week in 32. + +Nothing is transmitted by this route. + +The payload contains **no identifier**: no client id, no instance id, no hostname, no URLs, no keys, and no library or media names. Counts are bucketed so exact numbers never leave, and every list is deduplicated, sorted and truncated. + +This ignores the on-off state entirely and renders a preview even when telemetry is switched off. + +### `POST /api/settings/telemetry` + +**Turn the weekly anonymous telemetry ping on or off.** + +Request body is `{ "enabled": true }`. + +| Status | Cause | +| -------------------------- | -------------------------------------------------------------- | +| `201` | Saved | +| `201` with `status: "NOK"` | `TELEMETRY=off is set in the environment`, or the write failed | +| `400` | `enabled` is missing or not a boolean | + +The environment variable wins at read time too, so the stored value can be on while nothing is ever sent. + +There is deliberately no "send now" endpoint, because every real send counts towards the census and a test button would let one instance inflate it. diff --git a/docs/api/streamystats.md b/docs/api/streamystats.md new file mode 100644 index 000000000..a9a77514b --- /dev/null +++ b/docs/api/streamystats.md @@ -0,0 +1,109 @@ +--- +slug: /api/streamystats +title: Streamystats API +description: Streamystats endpoints for server info and per-item watch statistics. +--- + +Two read-only endpoints that surface [Streamystats](https://github.com/fredrikburmester/streamystats) watch data inside the media modal. Both are **Jellyfin only**: with Plex or Emby as the active media server they answer `403`. + +Both need a saved Streamystats URL **and** a saved Jellyfin API key. The Streamystats client is only built when both are present, so a missing Jellyfin key looks exactly like a missing Streamystats URL. + +See [API conventions](../API.md#api-conventions) for the rules that apply to every endpoint. + +## Endpoints + +### `GET /api/streamystats/info` + +**Return the configured Streamystats URL and the Streamystats server id that matches your Jellyfin server.** + +Maintainerr asks Streamystats for its server list and matches an entry against your Jellyfin server, first by URL and then by a case-insensitive name match. The match is remembered until Streamystats settings or Jellyfin settings change. The UI calls this when a media modal opens and uses the result to build a deep link of the form `/servers//library/`. + +Response: + +```json +{ + "url": "https://streamystats.example.com", + "serverId": 1 +} +``` + +`url` is the saved value exactly as stored, with no trailing-slash normalisation. `serverId` is `null` when no Streamystats server matched your Jellyfin instance or when the server list could not be read. + +| Status | Cause | +| ------ | ---------------------------------------------------------------------------------- | +| `200` | Read succeeded. `serverId` may still be `null` | +| `403` | The active media server is not Jellyfin | +| `404` | Streamystats is not configured, meaning no Streamystats URL or no Jellyfin API key | + +A `serverId` of `null` is not an error and does not change the status code. Maintainerr retries the server-list lookup on every request until a match is found, so an unreachable Streamystats instance keeps answering `200` with a null id, and the UI quietly drops the Streamystats link. + +### `GET /api/streamystats/items/{itemId}` + +**Return Streamystats watch statistics for one Jellyfin library item.** + +Resolves the Streamystats server id the same way as `/info`, then asks Streamystats for that item's statistics. The response is validated before it is returned, and numeric fields are coerced from strings because Streamystats sends aggregate totals as text. + +| Parameter | Type | Required | Description | +| --------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `itemId` | string | Yes | Jellyfin item id, the same id used by `GET /api/media-server/meta/{id}`. It is not validated and is passed to Streamystats unescaped | + +Response: + +```json +{ + "item": { "id": "abc123", "name": "Example title", "type": "Series" }, + "totalViews": 12, + "totalWatchTime": 34567, + "completionRate": 0.87, + "firstWatched": "2026-01-02T10:00:00.000Z", + "lastWatched": "2026-03-04T21:15:00.000Z", + "usersWatched": [ + { + "user": { "id": "u1", "name": "example-user" }, + "watchCount": 3, + "totalWatchTime": 8400, + "completionRate": 1, + "firstWatched": "2026-01-02T10:00:00.000Z", + "lastWatched": "2026-02-01T20:00:00.000Z" + } + ], + "watchHistory": [ + { + "user": { "id": "u1", "name": "example-user" }, + "watchDate": "2026-02-01T20:00:00.000Z", + "watchDuration": 2800, + "completionPercentage": 98, + "playMethod": "DirectPlay", + "deviceName": "Living room", + "clientName": "Jellyfin Web" + } + ], + "watchCountByMonth": [ + { + "month": 2, + "year": 2026, + "watchCount": 4, + "uniqueUsers": 2, + "totalWatchTime": 11200 + } + ], + "episodeStats": { + "totalSeasons": 3, + "totalEpisodes": 30, + "watchedEpisodes": 21, + "watchedSeasons": 2 + } +} +``` + +`episodeStats` is only present for series. `firstWatched` and `lastWatched` are `null` when nothing has been watched. + +| Status | Cause | +| ------ | --------------------------------------------------------------------- | +| `200` | Statistics returned | +| `403` | The active media server is not Jellyfin | +| `404` | Streamystats is not configured, or no data is available for this item | + +The second `404` covers four different situations: the Streamystats server id could not be resolved, Streamystats does not know the item, the request to Streamystats failed or timed out, and the payload failed validation. A `404` is therefore not proof that the item has no watch history. + +Successful responses are cached in memory for 20 minutes, keyed by item and server id. A transport failure is never cached and is retried on the next request, but a response that fails validation is cached, so that item keeps answering `404` for the full 20 minutes. diff --git a/sidebars.js b/sidebars.js index 8315a26ae..9e646935e 100644 --- a/sidebars.js +++ b/sidebars.js @@ -54,7 +54,21 @@ const sidebars = { { type: "category", label: "API", - items: ["api"], + items: [ + "api", + "api/settings", + "api/rules", + "api/overlays", + "api/collections", + "api/media-server", + "api/notifications", + "api/app-and-health", + "api/logs", + "api/seerr", + "api/metadata-and-storage", + "api/servarr", + "api/streamystats", + ], }, ], }; diff --git a/static/openapi-spec/maintainerr_api_specs.yaml b/static/openapi-spec/maintainerr_api_specs.yaml index 6e51271dd..e7357af46 100644 --- a/static/openapi-spec/maintainerr_api_specs.yaml +++ b/static/openapi-spec/maintainerr_api_specs.yaml @@ -1,752 +1,1713 @@ openapi: 3.0.0 paths: - /api/health: + /api/app/status: get: - operationId: HealthController_health + operationId: AppController_getAppStatus parameters: [] responses: '200': - description: Combined readiness check. Returns database status when reachable. - content: - application/json: - schema: - $ref: '#/components/schemas/HealthResponse' - '503': - description: Combined readiness check. Returned when the database is unreachable. + description: '' content: application/json: schema: - $ref: '#/components/schemas/HealthResponse' + type: string tags: - - /health - /api/health/live: + - App + /api/app/timezone: get: - operationId: HealthController_live + operationId: AppController_getAppTimezone parameters: [] responses: '200': - description: Liveness probe. Returns process uptime without touching the database. + description: '' content: application/json: schema: - $ref: '#/components/schemas/LivenessResponse' + type: string tags: - - /health - /api/health/ready: + - App + /api/app/releases: get: - operationId: HealthController_ready + operationId: AppController_getGitHubReleases parameters: [] responses: '200': - description: Readiness probe. Returns database status when reachable. + description: '' content: application/json: schema: - $ref: '#/components/schemas/HealthResponse' - '503': - description: Readiness probe. Returned when the database is unreachable. + type: array + items: + type: object + tags: + - App + /api/health/live: + get: + operationId: HealthController_live + parameters: [] + responses: + '200': + description: '' content: application/json: schema: - $ref: '#/components/schemas/HealthResponse' + type: object tags: - - /health - /api/app/status: + - Health + /api/health/ready: get: - operationId: AppController_getAppStatus + operationId: HealthController_ready parameters: [] responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /app - /api/settings: + - Health + /api/health: get: - operationId: SettingsController_getSettings + operationId: HealthController_health parameters: [] responses: '200': - description: Successful Response + description: '' content: application/json: schema: - $ref: '#/components/schemas/SettingDto' + type: object tags: - - /settings - post: - operationId: SettingsController_updateSettings + - Health + /api/logs/stream: + get: + operationId: LogsController_stream parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SettingDto' responses: - '201': + '200': description: '' tags: - - /settings - /api/settings/version: + - Logs + /api/logs/files: get: - operationId: SettingsController_getVersion + operationId: LogsController_getFiles parameters: [] responses: '200': description: '' + content: + application/json: + schema: + type: array + items: + type: object tags: - - /settings - /api/settings/api/generate: + - Logs + /api/logs/files/{file}: get: - operationId: SettingsController_generateApiKey - parameters: [] + operationId: LogsController_getFile + parameters: + - name: file + required: true + in: path + schema: + type: string responses: '200': description: '' tags: - - /settings - /api/settings/plex/auth: - delete: - operationId: SettingsController_deletePlexApiAuth + - Logs + /api/logs/settings: + get: + operationId: LogsController_getLogSettings parameters: [] responses: '200': description: '' tags: - - /settings - /api/settings/plex/token: + - Logs post: - operationId: SettingsController_updateAuthToken + operationId: LogsController_setLogSettings parameters: [] responses: '201': description: '' tags: - - /settings - /api/settings/test/setup: - get: - operationId: SettingsController_testSetup + - Logs + /api/logs/client-error: + post: + operationId: LogsController_logClientError parameters: [] responses: - '200': + '201': description: '' tags: - - /settings - /api/settings/test/overseerr: + - Logs + /api/settings: get: - operationId: SettingsController_testOverseerr + operationId: SettingsController_getSettings parameters: [] responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /settings - /api/settings/test/radarr: - get: - operationId: SettingsController_testRadarr + - settings + post: + operationId: SettingsController_updateSettings parameters: [] responses: - '200': + '201': description: '' + content: + application/json: + schema: + type: object tags: - - /settings - /api/settings/test/sonarr: - get: - operationId: SettingsController_testSonarr + - settings + patch: + operationId: SettingsController_patchSettings parameters: [] responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /settings - /api/settings/test/plex: + - settings + /api/settings/radarr: get: - operationId: SettingsController_testPlex + operationId: SettingsController_getRadarrSettings parameters: [] responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /settings - /api/settings/test/tautulli: - get: - operationId: SettingsController_testTautulli + - settings + post: + operationId: SettingsController_addRadarrSetting parameters: [] responses: - '200': + '201': description: '' + content: + application/json: + schema: + type: object tags: - - /settings - /api/settings/tmdb: + - settings + /api/settings/sonarr: get: - operationId: SettingsController_getTmdbSetting + operationId: SettingsController_getSonarrSettings parameters: [] responses: '200': - description: Returns the saved TMDB API key state. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings + - settings post: - operationId: SettingsController_updateTmdbSetting + operationId: SettingsController_addSonarrSetting parameters: [] - requestBody: - required: true - content: - application/json: - schema: - type: object responses: '201': - description: Saves a TMDB API key. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings - delete: - operationId: SettingsController_removeTmdbSetting + - settings + /api/settings/sportarr: + get: + operationId: SettingsController_getSportarrSettings parameters: [] responses: '200': - description: Removes the saved TMDB API key. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings - /api/settings/test/tmdb: + - settings post: - operationId: SettingsController_testTmdb + operationId: SettingsController_addSportarrSetting parameters: [] - requestBody: - required: true - content: - application/json: - schema: - type: object responses: '201': - description: Tests a TMDB API key. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings - /api/settings/tvdb: + - settings + /api/settings/version: get: - operationId: SettingsController_getTvdbSetting + operationId: SettingsController_getVersion parameters: [] responses: '200': - description: Returns the saved TVDB API key state. - tags: - - /settings - post: - operationId: SettingsController_updateTvdbSetting - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - '201': - description: Saves a TVDB API key. + description: '' + content: + application/json: + schema: + type: string tags: - - /settings - delete: - operationId: SettingsController_removeTvdbSetting + - settings + /api/settings/database/download: + get: + operationId: SettingsController_downloadDatabase parameters: [] responses: '200': - description: Removes the saved TVDB API key. + description: '' tags: - - /settings - /api/settings/test/tvdb: - post: - operationId: SettingsController_testTvdb + - settings + /api/settings/api/generate: + get: + operationId: SettingsController_generateApiKey parameters: [] - requestBody: - required: true - content: - application/json: - schema: - type: object responses: - '201': - description: Tests a TVDB API key. + '200': + description: '' + content: + application/json: + schema: + type: string tags: - - /settings - /api/settings/metadata-provider: - get: - operationId: SettingsController_getMetadataProviderPreference + - settings + /api/settings/plex/auth: + delete: + operationId: SettingsController_deletePlexApiAuth parameters: [] responses: '200': - description: Returns the current primary metadata provider. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings + - settings + /api/settings/plex/token: post: - operationId: SettingsController_updateMetadataProviderPreference + operationId: SettingsController_updateAuthToken parameters: [] - requestBody: - required: true - content: - application/json: - schema: - type: object responses: '201': - description: Updates the primary metadata provider. - tags: - - /settings - /api/settings/telemetry/preview: - get: - operationId: SettingsController_previewTelemetry - parameters: [] - responses: - '200': - description: >- - Returns the exact report this server would send, detail block - included, so it can be reviewed before anything is sent. + description: '' tags: - - /settings - /api/settings/telemetry/status: + - settings + /api/settings/test/setup: get: - operationId: SettingsController_telemetryStatus + operationId: SettingsController_testSetup parameters: [] responses: '200': - description: >- - Returns forcedOff, set when TELEMETRY=off is in the environment, - with the ISO timestamps of the next weekly report and of the next - one that also carries the detail block. Both timestamps are null - when nothing is being sent. + description: '' + content: + application/json: + schema: + type: boolean tags: - - /settings - /api/settings/telemetry: + - settings + /api/settings/test/radarr: post: - operationId: SettingsController_updateTelemetrySetting + operationId: SettingsController_testRadarr parameters: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - enabled: - type: boolean - description: Whether the weekly usage report is sent. - required: - - enabled responses: '201': - description: >- - Stores the telemetry choice. Answers status NOK when TELEMETRY=off - is set in the environment, since a stored value would change - nothing. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings - /api/settings/metadata/refresh/{provider}: - post: - operationId: SettingsController_refreshMetadataCache + - settings + /api/settings/radarr/{id}: + put: + operationId: SettingsController_updateRadarrSetting parameters: - - name: provider + - name: id required: true in: path schema: - type: string - responses: - '201': - description: Clears cached metadata for the provider and starts a media refresh pass. - tags: - - /settings - /api/settings/plex/devices/servers: - get: - operationId: SettingsController_getPlexServers - parameters: [] + type: number responses: '200': description: '' - tags: - - /settings - /api/settings/download-client: - get: - operationId: SettingsController_getDownloadClientSetting - parameters: [] - responses: - '200': - description: Returns the saved qBittorrent connection and cleanup options. content: application/json: schema: - $ref: '#/components/schemas/DownloadClientSetting' + type: object tags: - - /settings - post: - operationId: SettingsController_updateDownloadClientSetting + - settings + delete: + operationId: SettingsController_deleteRadarrSetting + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/test/sonarr: + post: + operationId: SettingsController_testSonarr parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DownloadClientSetting' responses: '201': - description: Saves qBittorrent connection and cleanup options. + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/sonarr/{id}: + put: + operationId: SettingsController_updateSonarrSetting + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + type: object tags: - - /settings + - settings delete: - operationId: SettingsController_removeDownloadClientSetting - parameters: [] + operationId: SettingsController_deleteSonarrSetting + parameters: + - name: id + required: true + in: path + schema: + type: number responses: '200': - description: Removes the saved download-client connection settings. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings - /api/settings/test/download-client: + - settings + /api/settings/test/sportarr: post: - operationId: SettingsController_testDownloadClient + operationId: SettingsController_testSportarr parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DownloadClientSetting' responses: '201': - description: Tests the qBittorrent Web UI URL, credentials, and cleanup options. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings - /api/settings/emby: + - settings + /api/settings/sportarr/{id}: + put: + operationId: SettingsController_updateSportarrSetting + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + delete: + operationId: SettingsController_deleteSportarrSetting + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/tautulli: get: - operationId: SettingsController_getEmbySetting + operationId: SettingsController_getTautulliSetting parameters: [] responses: '200': - description: Returns the saved Emby connection settings. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings + - settings post: - operationId: SettingsController_saveEmbySettings + operationId: SettingsController_updateTautlliSetting parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/EmbySetting' responses: '201': - description: Saves Emby connection settings. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings + - settings delete: - operationId: SettingsController_removeEmbySettings + operationId: SettingsController_removeTautlliSetting parameters: [] responses: '200': - description: Removes the saved Emby connection settings. - tags: - - /settings - /api/settings/emby/test: - post: - operationId: SettingsController_testEmby - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/EmbySetting' - responses: - '201': - description: Tests Emby connectivity and returns available admin users. + description: '' tags: - - /settings - /api/settings/emby/login: + - settings + /api/settings/test/tautulli: post: - operationId: SettingsController_loginEmby + operationId: SettingsController_testTautulli parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/EmbyLoginRequest' responses: '201': - description: Authenticates against Emby with admin credentials. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings + - settings /api/settings/streamystats: get: operationId: SettingsController_getStreamystatsSetting parameters: [] responses: '200': - description: Returns the saved Streamystats base URL. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings + - settings post: operationId: SettingsController_updateStreamystatsSetting parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/StreamystatsSetting' responses: '201': - description: Saves the Streamystats base URL. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings + - settings delete: operationId: SettingsController_removeStreamystatsSetting parameters: [] responses: '200': - description: Removes the saved Streamystats base URL. + description: '' tags: - - /settings + - settings /api/settings/test/streamystats: post: operationId: SettingsController_testStreamystats parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/StreamystatsSetting' responses: '201': - description: Tests Streamystats connectivity with the configured Jellyfin API key. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings + - settings /api/settings/tracearr: get: operationId: SettingsController_getTracearrSetting parameters: [] responses: '200': - description: Returns the saved Tracearr URL, API key, and selected server. + description: '' content: application/json: schema: - $ref: '#/components/schemas/TracearrSettingForm' + type: object tags: - - /settings + - settings post: operationId: SettingsController_updateTracearrSetting parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TracearrSetting' responses: '201': - description: Saves Tracearr connection settings. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings + - settings delete: operationId: SettingsController_removeTracearrSetting parameters: [] responses: '200': - description: Removes the saved Tracearr connection settings. + description: '' + content: + application/json: + schema: + type: object tags: - - /settings - /api/settings/test/tracearr: + - settings + /api/settings/tracearr/servers: post: - operationId: SettingsController_testTracearr + operationId: SettingsController_getTracearrServers parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TracearrSetting' responses: '201': - description: Tests Tracearr connectivity with the supplied URL and API key. + description: '' tags: - - /settings - /api/settings/tracearr/servers: + - settings + /api/settings/test/tracearr: post: - operationId: SettingsController_getTracearrServers + operationId: SettingsController_testTracearr parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TracearrConnection' responses: '201': - description: Returns the Tracearr servers available for the supplied URL and API key. + description: '' content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/TracearrServer' + type: object tags: - - /settings - /api/settings/cron/validate: + - settings + /api/settings/download-client: + get: + operationId: SettingsController_getDownloadClientSetting + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings post: - operationId: SettingsController_validateSingleCron + operationId: SettingsController_updateDownloadClientSetting parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CronScheduleDto' responses: '201': description: '' + content: + application/json: + schema: + type: object tags: - - /settings - /api/streamystats/info: - get: - operationId: StreamystatsApiController_getInfo + - settings + delete: + operationId: SettingsController_removeDownloadClientSetting parameters: [] responses: '200': - description: Returns the configured Streamystats URL and resolved Jellyfin server id. + description: '' + tags: + - settings + /api/settings/test/download-client: + post: + operationId: SettingsController_testDownloadClient + parameters: [] + responses: + '201': + description: '' content: application/json: schema: - $ref: '#/components/schemas/StreamystatsInfoResponse' + type: object tags: - - /streamystats - /api/streamystats/items/{itemId}: + - settings + /api/settings/tmdb: get: - operationId: StreamystatsApiController_getItemDetails - parameters: - - name: itemId - required: true - in: path - schema: - type: string + operationId: SettingsController_getTmdbSetting + parameters: [] responses: '200': - description: Returns Streamystats watch-history details for one Jellyfin item. + description: '' content: application/json: schema: - $ref: '#/components/schemas/StreamystatsItemDetails' + type: object tags: - - /streamystats - /api/media-server: - get: - operationId: MediaServerController_getStatus + - settings + post: + operationId: SettingsController_updateTmdbSetting parameters: [] responses: - '200': + '201': description: '' + content: + application/json: + schema: + type: object tags: - - /media-server - /api/media-server/libraries: - get: - operationId: MediaServerController_getLibraries + - settings + delete: + operationId: SettingsController_removeTmdbSetting parameters: [] responses: '200': description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/test/tmdb: + post: + operationId: SettingsController_testTmdb + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/tvdb: + get: + operationId: SettingsController_getTvdbSetting + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + post: + operationId: SettingsController_updateTvdbSetting + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + delete: + operationId: SettingsController_removeTvdbSetting + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/test/tvdb: + post: + operationId: SettingsController_testTvdb + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/metadata-provider: + get: + operationId: SettingsController_getMetadataProviderPreference + parameters: [] + responses: + '200': + description: '' + tags: + - settings + post: + operationId: SettingsController_updateMetadataProviderPreference + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/telemetry/preview: + get: + operationId: SettingsController_previewTelemetry + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/telemetry/status: + get: + operationId: SettingsController_telemetryStatus + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/telemetry: + post: + operationId: SettingsController_updateTelemetrySetting + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/metadata/refresh/{provider}: + post: + operationId: SettingsController_refreshMetadataCache + parameters: + - name: provider + required: true + in: path + schema: + enum: + - tmdb + - tvdb + - sportarr + type: string + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/seerr: + get: + operationId: SettingsController_getSeerrSetting[0] + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + post: + operationId: SettingsController_updateSeerrSetting[0] + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + delete: + operationId: SettingsController_removeSeerrSetting[0] + parameters: [] + responses: + '200': + description: '' + tags: + - settings + /api/settings/overseerr: + get: + operationId: SettingsController_getSeerrSetting[1] + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + post: + operationId: SettingsController_updateSeerrSetting[1] + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + delete: + operationId: SettingsController_removeSeerrSetting[1] + parameters: [] + responses: + '200': + description: '' + tags: + - settings + /api/settings/jellyseerr: + get: + operationId: SettingsController_getSeerrSetting[2] + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + post: + operationId: SettingsController_updateSeerrSetting[2] + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + delete: + operationId: SettingsController_removeSeerrSetting[2] + parameters: [] + responses: + '200': + description: '' + tags: + - settings + /api/settings/test/seerr: + post: + operationId: SettingsController_testSeerr[0] + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/test/overseerr: + post: + operationId: SettingsController_testSeerr[1] + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/test/jellyseerr: + post: + operationId: SettingsController_testSeerr[2] + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/jellyfin: + get: + operationId: SettingsController_getJellyfinSetting + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + post: + operationId: SettingsController_saveJellyfinSettings + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + delete: + operationId: SettingsController_removeJellyfinSettings + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/jellyfin/test: + post: + operationId: SettingsController_testJellyfin + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/emby: + get: + operationId: SettingsController_getEmbySetting + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + post: + operationId: SettingsController_saveEmbySettings + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + delete: + operationId: SettingsController_removeEmbySettings + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/emby/test: + post: + operationId: SettingsController_testEmby + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/emby/login: + post: + operationId: SettingsController_loginEmby + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/test/plex: + get: + operationId: SettingsController_testPlex + parameters: [] + responses: + '200': + description: Plex connectivity test result + summary: Test Plex server connectivity + tags: + - settings + /api/settings/test/plex/auth: + get: + operationId: SettingsController_testPlexAuth + parameters: [] + responses: + '200': + description: Plex auth token validation result + summary: Validate stored Plex authentication token + tags: + - settings + /api/settings/plex/devices/servers: + get: + operationId: SettingsController_getPlexServers + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + type: object + tags: + - settings + /api/settings/cron/validate: + post: + operationId: SettingsController_validateSingleCron + parameters: [] + responses: + '201': + description: '' + tags: + - settings + /api/settings/media-server/switch/preview/{targetServerType}: + get: + operationId: SettingsController_previewMediaServerSwitch + parameters: + - name: targetServerType + required: true + in: path + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/settings/media-server/switch: + post: + operationId: SettingsController_switchMediaServer + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - settings + /api/media-server: + get: + operationId: MediaServerController_getStatus + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - MediaServer + /api/media-server/type: + get: + operationId: MediaServerController_getServerType + parameters: [] + responses: + '200': + description: '' + tags: + - MediaServer + /api/media-server/libraries: + get: + operationId: MediaServerController_getLibraries + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + type: object + tags: + - MediaServer + /api/media-server/overview/bootstrap: + get: + operationId: MediaServerController_getOverviewBootstrap + parameters: + - name: limit + required: false + in: query + schema: + type: number + - name: sort + required: false + in: query + schema: + type: string + - name: sortOrder + required: false + in: query + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - MediaServer + /api/media-server/library/{id}/content: + get: + operationId: MediaServerController_getLibraryContent + parameters: + - name: id + required: true + in: path + schema: + type: string + - name: page + required: false + in: query + schema: + type: number + - name: limit + required: false + in: query + schema: + type: number + - name: type + required: false + in: query + schema: + type: string + - name: sort + required: false + in: query + schema: + type: string + - name: sortOrder + required: false + in: query + schema: + type: string + responses: + '200': + description: '' + tags: + - MediaServer + /api/media-server/library/{id}/content/search/{query}: + get: + operationId: MediaServerController_searchLibraryContent + parameters: + - name: id + required: true + in: path + schema: + type: string + - name: query + required: true + in: path + schema: + type: string + - name: type + required: false + in: query + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + type: object + tags: + - MediaServer + /api/media-server/library/{id}/recent: + get: + operationId: MediaServerController_getRecentlyAdded + parameters: + - name: id + required: true + in: path + schema: + type: string + - name: limit + required: false + in: query + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + type: object + tags: + - MediaServer + /api/media-server/users: + get: + operationId: MediaServerController_getUsers + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + type: object + tags: + - MediaServer + /api/media-server/user/{id}: + get: + operationId: MediaServerController_getUser + parameters: + - name: id + required: true + in: path + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - MediaServer + /api/media-server/meta/{id}: + get: + operationId: MediaServerController_getMetadata + parameters: + - name: id + required: true + in: path + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - MediaServer + /api/media-server/meta/{id}/maintainerr-status: + get: + operationId: MediaServerController_getMaintainerrStatusDetails + parameters: + - name: id + required: true + in: path + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - MediaServer + /api/media-server/meta/{id}/children: + get: + operationId: MediaServerController_getChildrenMetadata + parameters: + - name: id + required: true + in: path + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + type: object + tags: + - MediaServer + /api/media-server/meta/{id}/seen: + get: + operationId: MediaServerController_getWatchHistory + parameters: + - name: id + required: true + in: path + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + type: object + tags: + - MediaServer + /api/media-server/search/{query}: + get: + operationId: MediaServerController_searchContent + parameters: + - name: query + required: true + in: path + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + type: object + tags: + - MediaServer + /api/media-server/library/{id}/collections: + get: + operationId: MediaServerController_getCollections + parameters: + - name: id + required: true + in: path + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + type: object + tags: + - MediaServer + /api/media-server/collection/{id}: + get: + operationId: MediaServerController_getCollection + parameters: + - name: id + required: true + in: path + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - MediaServer + delete: + operationId: MediaServerController_deleteCollection + parameters: + - name: id + required: true + in: path + schema: + type: string + responses: + '200': + description: '' + tags: + - MediaServer + /api/media-server/collection/{id}/children: + get: + operationId: MediaServerController_getCollectionChildren + parameters: + - name: id + required: true + in: path + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + type: object + tags: + - MediaServer + /api/media-server/collection: + post: + operationId: MediaServerController_createCollection + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object tags: - - /media-server - /api/media-server/library/{id}/content: - get: - operationId: MediaServerController_getLibraryContent + - MediaServer + put: + operationId: MediaServerController_updateCollection + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - MediaServer + /api/media-server/collection/{collectionId}/item/{itemId}: + put: + operationId: MediaServerController_addToCollection parameters: - - name: id + - name: collectionId required: true in: path schema: type: string - - name: page - required: false - in: query + - name: itemId + required: true + in: path schema: - type: number - - name: limit - required: false - in: query + type: string + responses: + '200': + description: '' + tags: + - MediaServer + delete: + operationId: MediaServerController_removeFromCollection + parameters: + - name: collectionId + required: true + in: path schema: - type: number + type: string + - name: itemId + required: true + in: path + schema: + type: string responses: '200': description: '' tags: - - /media-server - /api/media-server/meta/{id}: + - MediaServer + /api/media-server/collection/visibility: + put: + operationId: MediaServerController_updateCollectionVisibility + parameters: [] + responses: + '200': + description: '' + tags: + - MediaServer + /api/servarr/sonarr/{id}/diskspace: get: - operationId: MediaServerController_getMetadata + operationId: ServarrApiController_getSonarrDiskspace parameters: - name: id required: true in: path schema: - type: string + type: number responses: '200': description: '' + content: + application/json: + schema: + type: array + items: + type: object tags: - - /media-server - /api/media-server/meta/{id}/seen: + - ServarrApi + /api/servarr/radarr/{id}/diskspace: get: - operationId: MediaServerController_getWatchHistory + operationId: ServarrApiController_getRadarrDiskspace parameters: - name: id required: true in: path schema: - type: string + type: number responses: '200': description: '' + content: + application/json: + schema: + type: array + items: + type: object tags: - - /media-server - /api/media-server/users: + - ServarrApi + /api/servarr/radarr/{id}/profiles: get: - operationId: MediaServerController_getUsers - parameters: [] + operationId: ServarrApiController_getRadarrProfiles + parameters: + - name: id + required: true + in: path + schema: + type: number responses: '200': description: '' + content: + application/json: + schema: + type: array + items: + type: object tags: - - /media-server - /api/media-server/meta/{id}/children: + - ServarrApi + /api/servarr/sonarr/{id}/profiles: get: - operationId: MediaServerController_getChildrenMetadata + operationId: ServarrApiController_getSonarrProfiles parameters: - name: id required: true in: path schema: - type: string + type: number responses: '200': description: '' + content: + application/json: + schema: + type: array + items: + type: object tags: - - /media-server - /api/media-server/library/{id}/recent: + - ServarrApi + /api/servarr/sportarr/{id}/profiles: get: - operationId: MediaServerController_getRecentlyAdded + operationId: ServarrApiController_getSportarrProfiles parameters: - name: id required: true in: path schema: - type: string + type: number responses: '200': description: '' + content: + application/json: + schema: + type: array + items: + type: object tags: - - /media-server - /api/media-server/library/{id}/collections: + - ServarrApi + /api/seerr/movie/{id}: get: - operationId: MediaServerController_getCollections + operationId: SeerrApiController_getMovie[0] parameters: - name: id required: true @@ -756,11 +1717,15 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /media-server - /api/media-server/collection/{id}: + - SeerrApi + /api/overseerr/movie/{id}: get: - operationId: MediaServerController_getCollection + operationId: SeerrApiController_getMovie[1] parameters: - name: id required: true @@ -770,10 +1735,15 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /media-server - delete: - operationId: MediaServerController_deleteCollection + - SeerrApi + /api/jellyseerr/movie/{id}: + get: + operationId: SeerrApiController_getMovie[2] parameters: - name: id required: true @@ -783,46 +1753,92 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /media-server - /api/media-server/collection/{id}/children: + - SeerrApi + /api/seerr/requests/{tmdbId}/users: get: - operationId: MediaServerController_getCollectionChildren + operationId: SeerrApiController_getRequestedByUsernames[0] parameters: - - name: id + - name: tmdbId required: true in: path schema: - type: string + type: number + - name: season + required: false + in: query + schema: + type: number responses: '200': description: '' + content: + application/json: + schema: + type: array + items: + type: string tags: - - /media-server - /api/media-server/search/{query}: + - SeerrApi + /api/overseerr/requests/{tmdbId}/users: get: - operationId: MediaServerController_searchContent + operationId: SeerrApiController_getRequestedByUsernames[1] parameters: - - name: query + - name: tmdbId required: true in: path schema: - type: string + type: number + - name: season + required: false + in: query + schema: + type: number responses: '200': description: '' + content: + application/json: + schema: + type: array + items: + type: string tags: - - /media-server - /api/media-server/collection/{collectionId}/item/{itemId}: - put: - operationId: MediaServerController_addToCollection + - SeerrApi + /api/jellyseerr/requests/{tmdbId}/users: + get: + operationId: SeerrApiController_getRequestedByUsernames[2] parameters: - - name: collectionId + - name: tmdbId required: true in: path schema: - type: string - - name: itemId + type: number + - name: season + required: false + in: query + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + type: string + tags: + - SeerrApi + /api/seerr/show/{id}: + get: + operationId: SeerrApiController_getShow[0] + parameters: + - name: id required: true in: path schema: @@ -830,17 +1846,35 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /media-server - delete: - operationId: MediaServerController_removeFromCollection + - SeerrApi + /api/overseerr/show/{id}: + get: + operationId: SeerrApiController_getShow[1] parameters: - - name: collectionId + - name: id required: true in: path schema: type: string - - name: itemId + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - SeerrApi + /api/jellyseerr/show/{id}: + get: + operationId: SeerrApiController_getShow[2] + parameters: + - name: id required: true in: path schema: @@ -848,51 +1882,89 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /media-server - /api/media-server/collection: - put: - operationId: MediaServerController_updateCollection - parameters: [] + - SeerrApi + /api/seerr/request/{requestId}: + delete: + operationId: SeerrApiController_deleteRequest[0] + parameters: + - name: requestId + required: true + in: path + schema: + type: string responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /media-server - post: - operationId: MediaServerController_createCollection - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateCollectionParams' + - SeerrApi + /api/overseerr/request/{requestId}: + delete: + operationId: SeerrApiController_deleteRequest[1] + parameters: + - name: requestId + required: true + in: path + schema: + type: string responses: - '201': + '200': description: '' + content: + application/json: + schema: + type: object tags: - - /media-server - /api/media-server/collection/visibility: - put: - operationId: MediaServerController_updateCollectionVisibility - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CollectionVisibilitySettings' + - SeerrApi + /api/jellyseerr/request/{requestId}: + delete: + operationId: SeerrApiController_deleteRequest[2] + parameters: + - name: requestId + required: true + in: path + schema: + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + tags: + - SeerrApi + /api/seerr/media/{mediaId}: + delete: + operationId: SeerrApiController_deleteMedia[0] + parameters: + - name: mediaId + required: true + in: path + schema: + type: string responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /media-server - /api/overseerr/movie/{id}: - get: - operationId: OverseerrApiController_getMovie + - SeerrApi + /api/overseerr/media/{mediaId}: + delete: + operationId: SeerrApiController_deleteMedia[1] parameters: - - name: id + - name: mediaId required: true in: path schema: @@ -900,13 +1972,17 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /overseerr - /api/overseerr/show/{id}: - get: - operationId: OverseerrApiController_getShow + - SeerrApi + /api/jellyseerr/media/{mediaId}: + delete: + operationId: SeerrApiController_deleteMedia[2] parameters: - - name: id + - name: mediaId required: true in: path schema: @@ -914,13 +1990,17 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /overseerr - /api/overseerr/request/{requestId}: + - SeerrApi + /api/seerr/media/tmdb/{mediaId}: delete: - operationId: OverseerrApiController_deleteRequest + operationId: SeerrApiController_removeMediaByTmdbId[0] parameters: - - name: requestId + - name: mediaId required: true in: path schema: @@ -928,11 +2008,15 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: boolean tags: - - /overseerr - /api/overseerr/media/{mediaId}: + - SeerrApi + /api/overseerr/media/tmdb/{mediaId}: delete: - operationId: OverseerrApiController_deleteMedia + operationId: SeerrApiController_removeMediaByTmdbId[1] parameters: - name: mediaId required: true @@ -942,11 +2026,15 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: boolean tags: - - /overseerr - /api/overseerr/media/tmdb/{mediaId}: + - SeerrApi + /api/jellyseerr/media/tmdb/{mediaId}: delete: - operationId: OverseerrApiController_removeMediaByTmdbId + operationId: SeerrApiController_removeMediaByTmdbId[2] parameters: - name: mediaId required: true @@ -956,55 +2044,108 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: boolean + tags: + - SeerrApi + /api/streamystats/info: + get: + operationId: StreamystatsApiController_getInfo + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: object tags: - - /overseerr - /api/moviedb/person/{personId}: + - StreamystatsApi + /api/streamystats/items/{itemId}: get: - operationId: TmdbApiController_getPerson + operationId: StreamystatsApiController_getItemDetails parameters: - - name: personId + - name: itemId required: true in: path schema: - type: number + type: string responses: '200': description: '' tags: - - /moviedb - /api/moviedb/movie/imdb/{id}: + - StreamystatsApi + /api/tasks/{id}/status: get: - operationId: TmdbApiController_getMovie + operationId: TasksController_getTaskStatus parameters: - name: id required: true in: path schema: - type: number + type: string + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/TaskStatusDto' + tags: + - Tasks + /api/metadata/backdrop/{type}: + get: + operationId: MetadataController_getBackdropImage + parameters: + - name: type + required: true + in: path + schema: + enum: + - movie + - show + type: string responses: '200': description: '' tags: - - /moviedb - /api/moviedb/image/{type}/{tmdbId}: + - Metadata + /api/metadata/overview/{type}: get: - operationId: TmdbApiController_getImage + operationId: MetadataController_getOverview parameters: - - name: tmdbId + - name: type required: true in: path schema: - type: number + enum: + - movie + - show + type: string + responses: + '200': + description: '' + tags: + - Metadata + /api/metadata/image/{type}: + get: + operationId: MetadataController_getImage + parameters: - name: type required: true in: path schema: + enum: + - movie + - show type: string responses: '200': description: '' tags: - - /moviedb + - Metadata /api/rules/constants: get: operationId: RulesController_getRuleConstants @@ -1012,17 +2153,27 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/RuleConstants' tags: - - /rules - /api/rules/schedule/update: - put: - operationId: RulesController_updateSchedule + - Rules + /api/rules/users: + get: + operationId: RulesController_getRuleUsernames parameters: [] responses: '200': description: '' + content: + application/json: + schema: + type: array + items: + type: string tags: - - /rules + - Rules /api/rules/community: get: operationId: RulesController_getCommunityRules @@ -1030,8 +2181,12 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /rules + - Rules post: operationId: RulesController_updateCommunityRules parameters: [] @@ -1044,8 +2199,25 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + type: object + tags: + - Rules + /api/rules/community/count: + get: + operationId: RulesController_getCommunityRuleCount + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: number tags: - - /rules + - Rules /api/rules/community/karma/history: get: operationId: RulesController_getCommunityRuleKarmaHistory @@ -1053,29 +2225,67 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CommunityRuleKarma' tags: - - /rules + - Rules /api/rules/exclusion: get: operationId: RulesController_getExclusion - parameters: [] + parameters: + - name: rulegroupId + required: false + in: query + schema: + type: number + - name: mediaServerId + required: false + in: query + schema: + type: string responses: '200': description: '' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Exclusion' tags: - - /rules + - Rules post: operationId: RulesController_setExclusion parameters: [] responses: '201': description: '' + content: + application/json: + schema: + type: object '409': - description: >- - A collection or rule run held the execution lock for too long. + description: A collection or rule run held the execution lock for too long. tags: - - /rules - /api/rules/{id}: + - Rules + /api/rules/count: + get: + operationId: RulesController_getRuleGroupCount + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: number + tags: + - Rules + /api/rules/{id}/rules: get: operationId: RulesController_getRules parameters: @@ -1083,124 +2293,246 @@ paths: required: true in: path schema: - type: string + type: number responses: '200': description: '' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Rules' tags: - - /rules - delete: - operationId: RulesController_deleteRuleGroup + - Rules + /api/rules/collection/{id}: + get: + operationId: RulesController_getRuleGroupByCollectionId parameters: - name: id required: true in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/RuleGroup' + tags: + - Rules + /api/rules: + get: + operationId: RulesController_getRuleGroups + parameters: + - name: activeOnly + required: false + in: query + schema: + type: string + - name: libraryId + required: false + in: query schema: type: string + - name: typeId + required: false + in: query + schema: + type: number responses: '200': description: '' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RuleGroupDto' tags: - - /rules - /api/rules/collection/{id}: + - Rules + post: + operationId: RulesController_setRules + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RuleGroupDto' + responses: + '201': + description: The rule group was created. + '400': + description: The rule group was rejected. The message names what was wrong. + '500': + description: The rule group could not be written. The cause is logged. + '502': + description: The media server could not be read to resolve the library. + '503': + description: The configured media server adapter could not initialize. + tags: + - Rules + put: + operationId: RulesController_updateRule + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RuleGroupDto' + responses: + '200': + description: The rule group was updated. + '400': + description: The rule group was rejected. The message names what was wrong. + '404': + description: The rule group does not exist. + '500': + description: The rule group could not be written. The cause is logged. + '502': + description: The media server could not be read to resolve the library. + '503': + description: The configured media server adapter could not initialize. + tags: + - Rules + /api/rules/{id}: get: - operationId: RulesController_getRuleGroupByCollectionId + operationId: RulesController_getRuleGroup parameters: - name: id required: true in: path schema: - type: string + type: number responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/RuleGroupDto' tags: - - /rules - /api/rules: - get: - operationId: RulesController_getRuleGroups - parameters: [] + - Rules + delete: + operationId: RulesController_deleteRuleGroup + parameters: + - name: id + required: true + in: path + schema: + type: number responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /rules + - Rules + /api/rules/execute: post: - operationId: RulesController_updateJob + operationId: RulesController_executeRules parameters: [] responses: '201': description: '' tags: - - /rules - put: - operationId: RulesController_updateRule + - Rules + /api/rules/{id}/execute: + post: + operationId: RulesController_executeRule + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '201': + description: '' + tags: + - Rules + /api/rules/execute/status: + get: + operationId: RulesController_getExecutionStatus parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RulesDto' responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /rules - /api/rules/execute: + - Rules + /api/rules/execute/stop: post: - operationId: RulesController_executeRules + operationId: RulesController_stopExecutingRules parameters: [] responses: - '201': - description: '' + '200': + description: The rules handler is already stopped. + '202': + description: The rules handler has been requested to stop. tags: - - /rules - /api/rules/exclusion/{id}: - delete: - operationId: RulesController_removeExclusion + - Rules + /api/rules/{id}/execute/stop: + post: + operationId: RulesController_stopExecutingRule parameters: - name: id required: true in: path schema: - type: string + type: number responses: '200': - description: '' + description: The rules handler is already stopped. + '202': + description: The rules handler has been requested to stop. tags: - - /rules + - Rules /api/rules/exclusions/bulk: post: operationId: RulesController_setBulkExclusions parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/BulkExclusionRequest' responses: '201': description: Per-item results; failures are reported per media id. + '400': + description: 'Rejected without processing: empty, or more than 250 media ids.' + '409': + description: A collection or rule run held the execution lock for too long. + tags: + - Rules + /api/rules/exclusion/{id}: + delete: + operationId: RulesController_removeExclusion + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' content: application/json: schema: - $ref: '#/components/schemas/BulkMediaResponse' - '400': - description: >- - Rejected without processing: empty, more than 250 media ids, or a - context sent with more than one id. - '409': - description: >- - A collection or rule run held the execution lock for too long. + type: object tags: - - /rules - /api/rules/exclusions/{plexId}: + - Rules + /api/rules/exclusions/{mediaServerId}: delete: operationId: RulesController_removeAllExclusion parameters: - - name: plexId + - name: mediaServerId required: true in: path schema: @@ -1208,8 +2540,12 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /rules + - Rules /api/rules/community/karma: post: operationId: RulesController_updateCommunityRuleKarma @@ -1217,8 +2553,12 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + type: object tags: - - /rules + - Rules /api/rules/yaml/encode: post: operationId: RulesController_yamlEncode @@ -1226,8 +2566,12 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + type: object tags: - - /rules + - Rules /api/rules/yaml/decode: post: operationId: RulesController_yamlDecode @@ -1235,8 +2579,12 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + type: object tags: - - /rules + - Rules /api/rules/test: post: operationId: RulesController_testRuleGroup @@ -1244,8 +2592,25 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + type: object + tags: + - Rules + /api/rules/migrate: + post: + operationId: RulesController_migrateRules + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object tags: - - /rules + - Rules /api/collections: post: operationId: CollectionsController_createCollection @@ -1254,7 +2619,7 @@ paths: '201': description: '' tags: - - /collections + - Collections put: operationId: CollectionsController_updateCollection parameters: [] @@ -1262,7 +2627,7 @@ paths: '200': description: '' tags: - - /collections + - Collections get: operationId: CollectionsController_getCollections parameters: @@ -1270,17 +2635,17 @@ paths: required: true in: query schema: - type: number + type: string - name: typeId required: true in: query schema: - type: number + type: string responses: '200': description: '' tags: - - /collections + - Collections /api/collections/add: post: operationId: CollectionsController_addToCollection @@ -1289,7 +2654,7 @@ paths: '201': description: '' tags: - - /collections + - Collections /api/collections/remove: post: operationId: CollectionsController_removeFromCollection @@ -1298,7 +2663,7 @@ paths: '201': description: '' tags: - - /collections + - Collections /api/collections/removeCollection: post: operationId: CollectionsController_removeCollection @@ -1306,8 +2671,12 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + type: object tags: - - /collections + - Collections /api/collections/handle: post: operationId: CollectionsController_handleCollection @@ -1316,7 +2685,7 @@ paths: '201': description: '' tags: - - /collections + - Collections /api/collections/schedule/update: put: operationId: CollectionsController_updateSchedule @@ -1324,8 +2693,12 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /collections + - Collections /api/collections/deactivate/{id}: get: operationId: CollectionsController_deactivate @@ -1338,8 +2711,12 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object tags: - - /collections + - Collections /api/collections/activate/{id}: get: operationId: CollectionsController_activate @@ -1352,8 +2729,39 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + type: object + tags: + - Collections + /api/collections/overlay-data: + get: + operationId: CollectionsController_getCollectionsForOverlayData + parameters: + - name: libraryId + required: false + in: query + description: Filter collections by library id. + schema: + type: string + - name: typeId + required: false + in: query + description: Filter collections by media item type. + schema: + enum: + - movie + - show + - season + - episode + type: string + responses: + '200': + description: Returns collections with full media arrays for overlay and helper integrations. + summary: Get collections with full media membership for overlay consumers tags: - - /collections + - Collections /api/collections/collection/{id}: get: operationId: CollectionsController_getCollection @@ -1366,8 +2774,12 @@ paths: responses: '200': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Collection' tags: - - /collections + - Collections /api/collections/media/add: post: operationId: CollectionsController_ManualActionOnCollection @@ -1375,8 +2787,43 @@ paths: responses: '201': description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Collection' + tags: + - Collections + /api/collections/media/bulk: + post: + operationId: CollectionsController_bulkMediaCollectionAction + parameters: [] + responses: + '201': + description: Per-item results; failures are reported per media id. + '400': + description: 'Rejected without processing: empty, more than 250 media ids, or an add without a collection.' + summary: Add or remove a media selection to or from one collection + tags: + - Collections + /api/collections/media/handle: + post: + operationId: CollectionsController_handleCollectionMedia + parameters: [] + responses: + '201': + description: '' + tags: + - Collections + /api/collections/media/postpone: + post: + operationId: CollectionsController_postponeCollectionMedia + parameters: [] + responses: + '200': + description: Returns the new addDate, the collection deleteAfterDays, and the resulting deletionDate. + summary: Postpone (or reset) the deletion timer for one collection item tags: - - /collections + - Collections /api/collections/media: delete: operationId: CollectionsController_deleteMediaFromCollection @@ -1385,7 +2832,7 @@ paths: required: true in: query schema: - type: number + type: string - name: collectionId required: true in: query @@ -1395,7 +2842,7 @@ paths: '200': description: '' tags: - - /collections + - Collections get: operationId: CollectionsController_getMediaInCollection parameters: @@ -1407,97 +2854,35 @@ paths: responses: '200': description: '' - tags: - - /collections - /api/collections/media/bulk: - post: - operationId: CollectionsController_bulkMediaCollectionAction - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/BulkCollectionMediaRequest' - responses: - '201': - description: Per-item results; failures are reported per media id. content: application/json: schema: - $ref: '#/components/schemas/BulkMediaResponse' - '400': - description: >- - Rejected without processing: empty, more than 250 media ids, or an - add without a collection. - tags: - - /collections - /api/collections/media/handle: - post: - operationId: CollectionsController_handleCollectionMedia - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - '201': - description: Runs the configured collection action for one item immediately. - tags: - - /collections - /api/collections/media/{id}/content/{page}: - get: - operationId: CollectionsController_getLibraryContent - parameters: - - name: id - required: true - in: path - schema: - type: number - - name: page - required: true - in: path - schema: - type: number - - name: size - required: true - in: query - schema: - type: number - responses: - '200': - description: '' + type: array + items: + $ref: '#/components/schemas/CollectionMedia' tags: - - /collections - /api/collections/exclusions/{id}/content/{page}: + - Collections + /api/collections/media/count: get: - operationId: CollectionsController_getExclusions + operationId: CollectionsController_getMediaInCollectionCount parameters: - - name: id - required: true - in: path - schema: - type: number - - name: page - required: true - in: path - schema: - type: number - - name: size - required: true + - name: collectionId + required: false in: query schema: type: number responses: '200': description: '' + content: + application/json: + schema: + type: number tags: - - /collections - /api/collections/logs/{id}/content/{page}: + - Collections + /api/collections/media/{id}/content/{page}: get: - operationId: CollectionsController_getCollectionLogs + operationId: CollectionsController_getLibraryContent parameters: - name: id required: true @@ -1509,184 +2894,204 @@ paths: in: path schema: type: number - - name: size - required: true - in: query - schema: - type: number - - name: search - required: true - in: query - schema: - type: string - name: sort - required: true - in: query - schema: - type: string - - name: filter - required: true - in: query - schema: - type: number - responses: - '200': - description: '' - tags: - - /collections - /api/collections/overlay-data: - get: - operationId: CollectionsController_getCollectionsForOverlayData - parameters: - - name: libraryId - required: false - in: query - schema: - type: string - - name: typeId - required: false - in: query - schema: - type: string - responses: - '200': - description: Returns collections with full media membership for overlay consumers. - tags: - - /collections - /api/metadata/backdrop/{type}: - get: - operationId: MetadataController_getBackdropImage - parameters: - - name: type - required: true - in: path - schema: - type: string - - name: itemId required: false in: query schema: type: string - - name: tmdbId + - name: sortOrder required: false in: query schema: - type: number - - name: tvdbId + type: string + - name: size required: false in: query schema: type: number - - name: imdbId - required: false - in: query - schema: - type: string responses: '200': - description: Resolves a backdrop image URL from the configured metadata providers. + description: '' tags: - - /metadata - /api/metadata/image/{type}: + - Collections + /api/collections/exclusions/{id}/content/{page}: get: - operationId: MetadataController_getImage + operationId: CollectionsController_getExclusions parameters: - - name: type + - name: id required: true in: path schema: - type: string - - name: itemId + type: number + - name: page + required: true + in: path + schema: + type: number + - name: sort required: false in: query schema: type: string - - name: tmdbId + - name: sortOrder required: false in: query schema: - type: number - - name: tvdbId + type: string + - name: size required: false in: query schema: type: number - - name: imdbId - required: false - in: query + responses: + '200': + description: '' + tags: + - Collections + /api/collections/{id}/poster: + get: + operationId: CollectionsController_getCollectionPoster + parameters: + - name: id + required: true + in: path schema: - type: string + type: number responses: '200': - description: Resolves a poster image URL from the configured metadata providers. + description: Returns the stored JPEG bytes. + '404': + description: No custom poster on this collection. + summary: Stream the user-uploaded poster bytes for a collection. 404 when none. tags: - - /metadata - /api/metadata/overview/{type}: + - Collections + post: + operationId: CollectionsController_uploadCollectionPoster + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '201': + description: Returns { pushed, attempted } so clients can distinguish a deferred local save from an attempted live media-server push. + content: + application/json: + schema: + type: object + required: + - pushed + - attempted + properties: + pushed: + type: boolean + description: True when the live media-server upload succeeded during this request. + attempted: + type: boolean + description: True when Maintainerr attempted a live media-server upload during this request. + summary: Upload a custom collection poster. Stored locally and pushed to the media server (best-effort). 500 KB max. + tags: + - Collections + delete: + operationId: CollectionsController_deleteCollectionPoster + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: Returns whether the local poster was cleared and whether Maintainerr successfully requested a media-server metadata refresh. + content: + application/json: + schema: + type: object + required: + - cleared + - refreshRequested + properties: + cleared: + type: boolean + description: True when the stored local poster file was removed. + refreshRequested: + type: boolean + description: True when Maintainerr successfully sent a metadata refresh request to the current media server. This does not guarantee that artwork will change. + summary: Clear the stored custom poster and request a best-effort metadata refresh on the media server. Artwork may or may not change depending on the configured server behavior and agents. + tags: + - Collections + /api/collections/logs/{id}/content/{page}: get: - operationId: MetadataController_getOverview + operationId: CollectionsController_getCollectionLogs parameters: - - name: type + - name: id required: true in: path schema: - type: string - - name: itemId - required: false + type: number + - name: page + required: true + in: path + schema: + type: number + - name: search + required: true in: query schema: type: string - - name: tmdbId + - name: sort required: false in: query schema: - type: number - - name: tvdbId - required: false + type: string + - name: filter + required: true in: query schema: type: number - - name: imdbId + - name: size required: false in: query schema: - type: string + type: number responses: '200': - description: Resolves an overview from the configured metadata providers. + description: '' tags: - - /metadata + - Collections /api/overlays/settings: get: operationId: OverlaysController_getSettings parameters: [] responses: '200': - description: Returns current overlay settings. + description: '' tags: - - /overlays + - Overlays put: operationId: OverlaysController_updateSettings parameters: [] - requestBody: - required: true - content: - application/json: - schema: - type: object responses: '200': - description: Updates and returns overlay settings. + description: '' tags: - - /overlays + - Overlays /api/overlays/sections: get: operationId: OverlaysController_getSections parameters: [] responses: '200': - description: Lists media server library sections for overlay preview. + description: '' + content: + application/json: + schema: + type: array + items: + type: object tags: - - /overlays + - Overlays /api/overlays/random-item: get: operationId: OverlaysController_getRandomItem @@ -1698,9 +3103,13 @@ paths: type: string responses: '200': - description: Returns a random media item for poster-template preview. + description: '' + content: + application/json: + schema: + type: object tags: - - /overlays + - Overlays /api/overlays/random-episode: get: operationId: OverlaysController_getRandomEpisode @@ -1712,53 +3121,45 @@ paths: type: string responses: '200': - description: Returns a random episode for title-card preview. + description: '' + content: + application/json: + schema: + type: object tags: - - /overlays + - Overlays /api/overlays/poster: get: operationId: OverlaysController_getPoster parameters: - - name: plexId + - name: itemId required: true in: query schema: type: string responses: '200': - description: Streams artwork used for overlay preview. + description: '' tags: - - /overlays + - Overlays /api/overlays/status: get: operationId: OverlaysController_getStatus parameters: [] responses: '200': - description: Returns current overlay processor status. + description: '' tags: - - /overlays + - Overlays /api/overlays/process: post: operationId: OverlaysController_processAll parameters: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - force: - type: boolean - description: Force a reapply pass even when overlay state already matches the current day count. responses: '202': - description: Starts overlay processing for all eligible collections. The run continues in the background; follow it on GET /api/overlays/status. - '409': - description: Returned when another overlay-processing run is already active. + description: '' tags: - - /overlays + - Overlays /api/overlays/process/{collectionId}: post: operationId: OverlaysController_processCollection @@ -1770,9 +3171,13 @@ paths: type: number responses: '201': - description: Runs overlay processing for one collection. + description: '' + content: + application/json: + schema: + type: object tags: - - /overlays + - Overlays /api/overlays/revert/{collectionId}: post: operationId: OverlaysController_revertCollection @@ -1784,43 +3189,35 @@ paths: type: number responses: '201': - description: Reverts overlays for one collection. + description: '' tags: - - /overlays + - Overlays /api/overlays/reset: delete: operationId: OverlaysController_resetAll parameters: [] responses: '202': - description: Starts reverting all overlays. The reset continues in the background; follow it on GET /api/overlays/status. - '409': - description: Returned when another overlay-processing run is already active. + description: '' tags: - - /overlays + - Overlays /api/overlays/fonts: get: operationId: OverlaysController_listFonts parameters: [] responses: '200': - description: Lists available overlay fonts. + description: '' tags: - - /overlays + - Overlays post: operationId: OverlaysController_uploadFont parameters: [] - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object responses: '201': - description: Uploads a custom overlay font. + description: '' tags: - - /overlays + - Overlays /api/overlays/fonts/{name}: get: operationId: OverlaysController_getFont @@ -1832,32 +3229,26 @@ paths: type: string responses: '200': - description: Streams a bundled or uploaded font file. + description: '' tags: - - /overlays + - Overlays /api/overlays/images: get: operationId: OverlaysController_listImages parameters: [] responses: '200': - description: Lists uploaded overlay image assets. + description: '' tags: - - /overlays + - Overlays post: operationId: OverlaysController_uploadImage parameters: [] - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object responses: '201': - description: Uploads a PNG, JPG, or WebP overlay image asset. + description: '' tags: - - /overlays + - Overlays /api/overlays/images/{name}: get: operationId: OverlaysController_getImage @@ -1869,9 +3260,9 @@ paths: type: string responses: '200': - description: Streams an uploaded overlay image asset. + description: '' tags: - - /overlays + - Overlays delete: operationId: OverlaysController_deleteImage parameters: @@ -1882,47 +3273,36 @@ paths: type: string responses: '200': - description: Deletes an uploaded overlay image asset. + description: '' tags: - - /overlays + - Overlays /api/overlays/templates: get: operationId: OverlaysController_listTemplates parameters: [] responses: '200': - description: Lists overlay templates. + description: '' + content: + application/json: + schema: + type: array + items: + type: object tags: - - /overlays + - Overlays post: operationId: OverlaysController_createTemplate parameters: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - '201': - description: Creates an overlay template. - tags: - - /overlays - /api/overlays/templates/import: - post: - operationId: OverlaysController_importTemplate - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - type: object responses: '201': - description: Imports an overlay template from JSON. + description: '' + content: + application/json: + schema: + type: object tags: - - /overlays + - Overlays /api/overlays/templates/{id}: get: operationId: OverlaysController_getTemplate @@ -1934,9 +3314,13 @@ paths: type: number responses: '200': - description: Returns one overlay template. + description: '' + content: + application/json: + schema: + type: object tags: - - /overlays + - Overlays put: operationId: OverlaysController_updateTemplate parameters: @@ -1945,17 +3329,15 @@ paths: in: path schema: type: number - requestBody: - required: true - content: - application/json: - schema: - type: object responses: '200': - description: Updates an overlay template. + description: '' + content: + application/json: + schema: + type: object tags: - - /overlays + - Overlays delete: operationId: OverlaysController_deleteTemplate parameters: @@ -1966,9 +3348,9 @@ paths: type: number responses: '200': - description: Deletes a non-preset overlay template. + description: '' tags: - - /overlays + - Overlays /api/overlays/templates/{id}/duplicate: post: operationId: OverlaysController_duplicateTemplate @@ -1980,9 +3362,13 @@ paths: type: number responses: '201': - description: Duplicates an overlay template. + description: '' + content: + application/json: + schema: + type: object tags: - - /overlays + - Overlays /api/overlays/templates/{id}/default: post: operationId: OverlaysController_setDefaultTemplate @@ -1994,9 +3380,13 @@ paths: type: number responses: '201': - description: Sets an overlay template as the default for its mode. + description: '' + content: + application/json: + schema: + type: object tags: - - /overlays + - Overlays /api/overlays/templates/{id}/export: post: operationId: OverlaysController_exportTemplate @@ -2008,511 +3398,343 @@ paths: type: number responses: '201': - description: Exports an overlay template as JSON. + description: '' + tags: + - Overlays + /api/overlays/templates/import: + post: + operationId: OverlaysController_importTemplate + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - Overlays + /api/overlays/templates/{id}/preview: + post: + operationId: OverlaysController_previewTemplate + parameters: + - name: id + required: true + in: path + schema: + type: number + - name: itemId + required: true + in: query + schema: + type: string + responses: + '201': + description: '' + tags: + - Overlays + /api/notifications/test: + post: + operationId: NotificationsController_sendTestNotification + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: string + tags: + - Notifications + /api/notifications/agents: + get: + operationId: NotificationsController_getNotificationAgents + parameters: [] + responses: + '200': + description: '' + tags: + - Notifications + /api/notifications/types: + get: + operationId: NotificationsController_getNotificationTypes + parameters: [] + responses: + '200': + description: '' + tags: + - Notifications + /api/notifications/configuration/add: + post: + operationId: NotificationsController_addNotificationConfiguration + parameters: [] + responses: + '201': + description: '' + content: + application/json: + schema: + type: object + tags: + - Notifications + /api/notifications/configuration/connect: + post: + operationId: NotificationsController_connectNotificationConfiguration + parameters: [] + responses: + '201': + description: '' tags: - - /overlays - /api/overlays/templates/{id}/preview: + - Notifications + /api/notifications/configuration/disconnect: post: - operationId: OverlaysController_previewTemplate + operationId: NotificationsController_disconnectionNotificationConfiguration + parameters: [] + responses: + '201': + description: '' + tags: + - Notifications + /api/notifications/configurations: + get: + operationId: NotificationsController_getNotificationConfigurations + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Notification' + tags: + - Notifications + /api/notifications/configuration/{id}: + delete: + operationId: NotificationsController_deleteNotificationConfiguration parameters: - name: id required: true in: path schema: type: number - - name: plexId - required: true - in: query - schema: - type: string responses: - '201': - description: Renders a preview for an overlay template. + '200': + description: '' + tags: + - Notifications + /api/events/stream: + get: + operationId: EventsController_stream + parameters: [] + responses: + '200': + description: '' tags: - - /overlays + - Events /api/storage-metrics: get: operationId: StorageMetricsController_getMetrics parameters: [] responses: '200': - description: Returns aggregated storage metrics, including cleanupTotals handled counters and reclaimed-byte totals plus collectionSummary per-type reclaim breakdowns. + description: Returns disk-usage totals, per-mount breakdowns, instance health and collection-size summaries. + summary: Aggregated disk space and collection storage metrics across all configured Radarr/Sonarr instances. tags: - - /storage-metrics + - StorageMetrics /api/storage-metrics/library-sizes: get: operationId: StorageMetricsController_getLibrarySizes parameters: [] responses: '200': - description: Computes accurate per-library sizes on demand. + description: Returns a map of media server library id → bytes. Libraries missing from the map could not be sized. + summary: Accurate per-library size computed by iterating media items. Potentially slow - call on demand. tags: - - /storage-metrics + - StorageMetrics info: - title: Maintainerr API - description: API documentation for Maintainerr - version: '1.0' + title: Maintainerr + description: '' + version: 1.0.0 contact: {} -tags: - - name: maintainerr - description: '' -servers: - - url: :/api +tags: [] +servers: [] components: schemas: - LivenessResponse: - type: object - properties: - status: - type: string - enum: - - ok - uptimeSeconds: - type: number - timestamp: - type: string - format: date-time - required: - - status - - uptimeSeconds - - timestamp - HealthResponse: - type: object - properties: - status: - type: string - enum: - - ok - - degraded - uptimeSeconds: - type: number - database: - type: string - enum: - - ok - - unreachable - timestamp: - type: string - format: date-time - required: - - status - - uptimeSeconds - - database - - timestamp - SettingDto: + TaskStatusDto: type: object properties: {} - DownloadClientSetting: - type: object - properties: - download_client_url: - type: string - download_client_username: - type: string - default: '' - download_client_password: - type: string - default: '' - download_client_delete_data: - type: boolean - download_client_fallback_ratio: - type: number - minimum: 0.5 - required: - - download_client_url - - download_client_delete_data - - download_client_fallback_ratio - EmbySetting: + RuleConstants: type: object - properties: - emby_url: - type: string - emby_api_key: - type: string - emby_user_id: - type: string - required: - - emby_url - - emby_api_key - EmbyLoginRequest: + properties: {} + CommunityRuleKarma: type: object - properties: - emby_url: - type: string - username: - type: string - password: - type: string - required: - - emby_url - - username - - password - StreamystatsSetting: + properties: {} + Exclusion: type: object - properties: - url: - type: string - required: - - url - TracearrConnection: + properties: {} + Rules: type: object - properties: - url: - type: string - api_key: - type: string - required: - - url - - api_key - TracearrSetting: + properties: {} + RuleGroup: type: object - properties: - url: - type: string - api_key: - type: string - server_id: - description: >- - Omit it to let Maintainerr pick the Tracearr server that tracks the - configured media server. Send it only when Tracearr has several - servers of that type. - type: string - format: uuid - required: - - url - - api_key - TracearrSettingForm: + properties: {} + Notification: type: object - description: >- - Saved Tracearr settings as stored. Every field is null until Tracearr - has been configured. - properties: - url: - type: string - nullable: true - api_key: - type: string - nullable: true - server_id: - type: string - nullable: true - TracearrServer: + properties: {} + RuleGroupDto: type: object properties: id: + type: number + libraryId: type: string - format: uuid name: type: string - required: - - id - - name - BulkMediaContext: - type: object - description: >- - Narrows a single-item selection to one season or episode. Rejected when - more than one media id is sent. - properties: - id: + description: + type: string + isActive: + type: boolean + arrAction: + type: number + useRules: + type: boolean + ruleHandlerCronSchedule: type: string - type: + nullable: true + collection: + type: object + listExclusions: + type: boolean + cleanupLeftoverFolders: + type: boolean + forceSeerr: + type: boolean + rules: + type: object + manualCollection: + type: boolean + manualCollectionName: type: string + dataType: enum: - movie - show - season - episode - required: - - id - - type - BulkExclusionRequest: - type: object - properties: - mediaIds: + type: string + tautulliWatchedPercentOverride: + type: number + notifications: type: array - minItems: 1 - maxItems: 250 items: - type: string - collectionId: - type: integer - description: >- - Scopes the exclusion to this collection's rule group. Omit for every - collection. - action: - type: integer - enum: - - 0 - - 1 - description: 0 adds an exclusion, 1 removes one. - context: - $ref: '#/components/schemas/BulkMediaContext' + $ref: '#/components/schemas/Notification' + radarrSettingsId: + type: number + sonarrSettingsId: + type: number + sportarrSettingsId: + type: number + radarrQualityProfileId: + type: number + sonarrQualityProfileId: + type: number + sportarrQualityProfileId: + type: number + tagInArr: + type: boolean + keepInMaintainerrOnly: + type: boolean required: - - mediaIds - BulkCollectionMediaRequest: + - libraryId + - name + - description + - rules + - dataType + RuleDto: type: object properties: - mediaIds: + firstVal: type: array - minItems: 1 - maxItems: 250 items: - type: string - collectionId: - type: integer - description: Required to add. Omit only for a removal from every collection. - action: - type: integer + type: number + lastVal: + type: array + items: + type: number + operator: + type: number + nullable: true enum: - 0 - 1 - description: 0 adds to the collection, 1 removes from it. - mediaType: - type: string - enum: - - movie - - show - - season - - episode - description: Lets the server resolve the hierarchy without a metadata read per item. - context: - $ref: '#/components/schemas/BulkMediaContext' - required: - - mediaIds - - action - - mediaType - BulkMediaItemResult: - type: object - properties: - mediaId: - type: string - code: - type: integer + action: + type: number enum: - 0 - 1 - description: 1 succeeded, 0 failed. - message: + - 2 + - 3 + - 4 + - 5 + - 6 + - 7 + - 8 + - 9 + - 10 + - 11 + - 12 + - 13 + - 14 + - 15 + - 16 + - 17 + - 18 + - 19 + customVal: + type: object + properties: + ruleTypeId: + type: number + value: + type: string + required: + - ruleTypeId + - value + arrDiskPath: type: string - required: - - mediaId - - code - BulkMediaResponse: - type: object - properties: - results: - type: array - items: - $ref: '#/components/schemas/BulkMediaItemResult' - required: - - results - StreamystatsInfoResponse: - type: object - properties: - url: + username: type: string - serverId: + section: type: number - nullable: true required: - - url - - serverId - StreamystatsUser: + - firstVal + - operator + - action + - section + CommunityRule: type: object properties: id: - type: string - name: - type: string - nullable: true - required: - - id - StreamystatsItemUserStats: - type: object - properties: - user: - $ref: '#/components/schemas/StreamystatsUser' - watchCount: - type: number - totalWatchTime: - type: number - completionRate: - type: number - firstWatched: - type: string - nullable: true - lastWatched: - type: string - nullable: true - required: - - user - - watchCount - - totalWatchTime - - completionRate - - firstWatched - - lastWatched - StreamystatsItemWatchHistory: - type: object - properties: - user: - allOf: - - $ref: '#/components/schemas/StreamystatsUser' - nullable: true - watchDate: - type: string - watchDuration: - type: number - completionPercentage: - type: number - playMethod: - type: string - nullable: true - deviceName: - type: string - nullable: true - clientName: - type: string - nullable: true - required: - - user - - watchDate - - watchDuration - - completionPercentage - StreamystatsItemWatchCountByMonth: - type: object - properties: - month: - type: number - year: - type: number - watchCount: - type: number - uniqueUsers: - type: number - totalWatchTime: - type: number - required: - - month - - year - - watchCount - - uniqueUsers - - totalWatchTime - StreamystatsSeriesEpisodeStats: - type: object - properties: - totalSeasons: - type: number - totalEpisodes: type: number - watchedEpisodes: + karma: type: number - watchedSeasons: - type: number - required: - - totalSeasons - - totalEpisodes - - watchedEpisodes - - watchedSeasons - StreamystatsItem: - type: object - properties: - id: + appVersion: type: string name: type: string - nullable: true - type: - type: string - nullable: true - required: - - id - StreamystatsItemDetails: - type: object - properties: - item: - $ref: '#/components/schemas/StreamystatsItem' - totalViews: - type: number - totalWatchTime: - type: number - completionRate: - type: number - firstWatched: - type: string - nullable: true - lastWatched: - type: string - nullable: true - usersWatched: - type: array - items: - $ref: '#/components/schemas/StreamystatsItemUserStats' - watchHistory: - type: array - items: - $ref: '#/components/schemas/StreamystatsItemWatchHistory' - watchCountByMonth: - type: array - items: - $ref: '#/components/schemas/StreamystatsItemWatchCountByMonth' - episodeStats: - allOf: - - $ref: '#/components/schemas/StreamystatsSeriesEpisodeStats' - nullable: true - required: - - item - - totalViews - - totalWatchTime - - completionRate - - firstWatched - - lastWatched - - usersWatched - - watchHistory - - watchCountByMonth - CronScheduleDto: - type: object - properties: {} - CollectionVisibilitySettings: - type: object - properties: - libraryId: - type: string - collectionId: - type: string - ownHome: - type: boolean - sharedHome: - type: boolean - recommended: - type: boolean - CreateCollectionParams: - type: object - properties: - libraryId: - type: string - title: - type: string - summary: - type: string - type: - type: string - enum: - - movie - - show - - season - - episode - sortTitle: - type: string - initialItemId: + description: type: string - description: Optional id of a single media-server item to include when the collection is created. + JsonRules: + $ref: '#/components/schemas/RuleDto' required: - - libraryId - - title - - type - RulesDto: + - name + - description + - JsonRules + Collection: type: object properties: {} - CommunityRule: + CollectionMedia: type: object properties: {} From 94e6fcad9f2019038e050209175c44b2286b5533 Mon Sep 17 00:00:00 2001 From: enoch85 Date: Sun, 30 Aug 2026 07:55:53 +0000 Subject: [PATCH 2/2] docs: state the missing body schema on each unvalidated endpoint Twelve endpoints documented a request body without saying that nothing validates it. Each now says so, with the concrete consequence where there is one: the yaml and migrate routes answer code 0 with Invalid input rather than a 400, and a misspelled field on the rule test route is read as missing instead of being rejected. The API conventions section says again that all seventeen carry the note, which is now true. --- docs/API.md | 2 +- docs/api/media-server.md | 4 ++-- docs/api/notifications.md | 6 ++++-- docs/api/rules.md | 14 ++++++++------ 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/API.md b/docs/API.md index 8abb15ecf..6a1a38548 100644 --- a/docs/API.md +++ b/docs/API.md @@ -66,7 +66,7 @@ Bodies are validated per endpoint. Where a schema exists, a failure returns: `errors` holds the individual validation problems. -Validation is not universal. Of the 70 endpoints that take a body, **17 have no schema at all**, so the body reaches the service unchecked. Most numeric path parameters are checked and reject a non-numeric value with a `400` before the handler runs, but not all: `DELETE /api/notifications/configuration/{id}` declares a numeric id without that check. +Validation is not universal. Of the 70 endpoints that take a body, **17 have no schema at all**, so the body reaches the service unchecked. Each of those 17 says so on its own entry. Most numeric path parameters are checked and reject a non-numeric value with a `400` before the handler runs, but not all: `DELETE /api/notifications/configuration/{id}` declares a numeric id without that check. ### Success and failure in the same status code diff --git a/docs/api/media-server.md b/docs/api/media-server.md index 4c87820d2..a51072930 100644 --- a/docs/api/media-server.md +++ b/docs/api/media-server.md @@ -521,7 +521,7 @@ Request body: } ``` -`libraryId` and `collectionId` are required. The rest are optional. +`libraryId` and `collectionId` are required. The rest are optional. Nothing validates the body, so a wrong `collectionId` is only caught when the media server rejects the write. | Status | Cause | | ------ | ----------------------------------------------------------- | @@ -556,7 +556,7 @@ Request body: } ``` -`libraryId` and `collectionId` are required, plus at least one of the three flags. +`libraryId` and `collectionId` are required, plus at least one of the three flags. That check is written by hand in the handler, and nothing else validates the body. | Status | Cause | | ------ | -------------------------------------------------------------------------------------- | diff --git a/docs/api/notifications.md b/docs/api/notifications.md index 070353473..93f4c7b12 100644 --- a/docs/api/notifications.md +++ b/docs/api/notifications.md @@ -232,7 +232,7 @@ Request body: { "rulegroupId": 1, "notificationId": 2 } ``` -Both ids are checked for truthiness, so `0` counts as missing. +Both ids are checked for truthiness, so `0` counts as missing. That is the only check the route makes, because nothing validates the body. Response: @@ -258,6 +258,8 @@ Request body: { "rulegroupId": 1, "notificationId": 2 } ``` +Both ids are checked for truthiness, so `0` counts as missing. Nothing else validates the body. + | Status | Cause | | ------ | ----------------------------------------------------------------------------------------------------------------- | | `201` | Always, including failure. A missing rule group or notification is `code: 0` with `result: "failed"`, not a `404` | @@ -284,7 +286,7 @@ The response is a bare JSON string, not an object. It is `Success`, or `Failure: Nothing is written to the database and the live agent list is untouched. Because no media items are involved, a test works even with no media server configured. :::warning This sends a real message to a destination you name in the request -The credentials and the target URL both come from the request body, so this route will make an outbound request to whatever address the caller supplies. The only check is that webhook style agents use an `http` or `https` scheme. There is no host or private network filtering, and Gotify's URL is not checked at all. +The credentials and the target URL both come from the request body, so this route will make an outbound request to whatever address the caller supplies. The only check is that webhook style agents use an `http` or `https` scheme, and nothing validates the body itself. There is no host or private network filtering, and Gotify's URL is not checked at all. On an unauthenticated instance that is a way to make your server issue requests on someone else's behalf. Treat this as an operator-only endpoint and see [Security and Authentication](../Security.md). ::: diff --git a/docs/api/rules.md b/docs/api/rules.md index 03108e469..68e2c70d1 100644 --- a/docs/api/rules.md +++ b/docs/api/rules.md @@ -157,7 +157,7 @@ Some values you send are deliberately overruled: `cleanupLeftoverFolders` is for **Update an existing rule group and rewrite all of its rules.** -Same body as the create route plus a required `id`. +Same body as the create route plus a required `id`. It is not schema-validated either. | Status | Cause | | ------ | ---------------------------------------------------------------------------------- | @@ -337,7 +337,7 @@ Request body: { "mediaId": "12345", "rulegroupId": 1 } ``` -Note the lower-case `g` in `rulegroupId`. `mediaId` may be a show, season or episode id. +Note the lower-case `g` in `rulegroupId`. `mediaId` may be a show, season or episode id. Nothing validates the body, so a misspelled field is not rejected but read as missing. Response on success is `code: 1` and a `result` array. Each entry carries `mediaServerId`, an overall `result` boolean, and `sectionResults`, each holding per-rule results with `firstValueName`, `firstValue`, `secondValue`, `action`, `operator` and `result`, plus reason strings when a value could not be read. @@ -396,6 +396,8 @@ Request body: { "mediaId": "12345", "collectionId": 1, "action": 0 } ``` +Nothing validates the body. The fields below are simply what the handler reads. + | Field | Type | Required | Description | | -------------- | ------ | -------- | --------------------------------------------------------------------- | | `mediaId` | string | Yes | Media server item id | @@ -575,7 +577,7 @@ Request body: { "rules": "[{\"action\":0,\"firstVal\":[0,1],\"section\":0}]" } ``` -`rules` is a **JSON-encoded string**, not an array. +`rules` is a **JSON-encoded string**, not an array. Nothing validates the body, and a string that will not parse comes back as `code: 0` with `Invalid input` rather than a `400`. | Status | Cause | | ------ | --------------------------------------------------------------------------------------------------------- | @@ -597,7 +599,7 @@ Request body: { "rules": "[]", "mediaType": "movie" } ``` -`rules` is a JSON-encoded string. +`rules` is a JSON-encoded string. Nothing validates the body, and a string that will not parse comes back as `code: 0` with `Invalid input` rather than a `400`. | Status | Cause | | ------ | ------------------------------------------------------------------------------------------------------------------------- | @@ -615,7 +617,7 @@ Request body: { "yaml": "...", "mediaType": "movie" } ``` -`mediaType` must match the document's own media type. +`mediaType` must match the document's own media type. Nothing validates the body itself. | Status | Cause | | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -687,7 +689,7 @@ Request body: { "id": 12, "karma": 6 } ``` -`karma` is the **absolute new value**, not a change. The UI computes it as the current karma plus or minus one. +`karma` is the **absolute new value**, not a change. The UI computes it as the current karma plus or minus one. Nothing validates the body. | Status | Cause | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |