Author SHA1 Message Date
gronod 958006cff2 Document #12 vote read upstream failures
Build and publish / Test and build (darwin) (pull_request) Successful in 1m50s
Build and publish / Test and build (linux) (pull_request) Successful in 2m21s
Build and publish / Test and build (windows) (pull_request) Successful in 3m6s
Build and publish / Build and publish Docker image (pull_request) Successful in 1m53s
Build and publish / Test and build (darwin) (push) Successful in 2m9s
Build and publish / Test and build (linux) (push) Successful in 2m31s
Build and publish / Test and build (windows) (push) Successful in 3m7s
Build and publish / Build and publish Docker image (push) Successful in 2m15s
2026-09-19 18:50:24 +01:00
gronod 7658bc8f39 Fix #8: fall back to search ratings metadata 2026-09-19 18:48:47 +01:00
gronod 6926a91c80 Fix M7 admin/server wire contracts
Build and publish / Test and build (darwin) (pull_request) Successful in 1m52s
Build and publish / Test and build (linux) (pull_request) Successful in 2m30s
Build and publish / Test and build (windows) (pull_request) Successful in 3m7s
Build and publish / Test and build (windows) (push) Successful in 3m7s
Build and publish / Test and build (darwin) (push) Successful in 1m45s
Build and publish / Test and build (linux) (push) Successful in 2m12s
Build and publish / Build and publish Docker image (pull_request) Successful in 1m49s
Build and publish / Build and publish Docker image (push) Successful in 2m4s
2026-09-19 18:36:03 +01:00
gronod 5b1be698d6 Document recent TV identity resolution and add a live callable check
Build and publish / Test and build (darwin) (pull_request) Successful in 1m52s
Build and publish / Test and build (linux) (pull_request) Successful in 2m26s
Build and publish / Test and build (windows) (pull_request) Successful in 3m6s
Build and publish / Test and build (windows) (push) Successful in 3m5s
Build and publish / Test and build (darwin) (push) Successful in 1m48s
Build and publish / Test and build (linux) (push) Successful in 2m10s
Build and publish / Build and publish Docker image (pull_request) Successful in 1m52s
Build and publish / Build and publish Docker image (push) Successful in 2m2s
The schema contract claimed recent target.id is requestId for every
kind — upstream TV rows actually carry a child request id there. The
docs now describe the bounded parent-scan resolution, the
ombi_tv_child identifier, and the id-0-with-warning degradation. A
live test proves a recent tv_parent target is callable via get.
2026-09-19 16:20:03 +01:00
gronod d07ef4f818 Treat requestId 0 as absent in the TV details overlay gate
Upstream Search/tv/info responses send requestId 0 (not a missing
field) for shows without a linked request, so the #7 request-state
overlay never fired on live instances — requested shows reported
requested:false. Now that the parent scan decodes the real collection
wrapper, gating on requestId nil-or-0 lets the overlay actually run.
2026-09-19 16:19:02 +01:00
gronod ff18e60d5a Resolve discover requested-fallback TV rows to tv_parent targets
The recentlyRequested fallback emitted tv_child targets for TV rows
while the primary requested-browse path emits tv_parent — and a
tv_child target is a dead end for read_requests get/children. Resolve
the child request id to the parent via the same bounded scan so both
paths agree; rows that fail resolution keep the truthful tv_child
target.
2026-09-19 16:18:04 +01:00
gronod 0ce9e2626b Surface parent provider ids on tv_child projections
ChildRequests carries no top-level provider ids upstream — they live
on the embedded parentRequest navigation property — so children/list
items emitted empty identifiers. Read them from the embedded record;
the child target id itself stays the true child PK (provider-shaped
for first-request children by upstream design, and callable for
child-scoped operations). Mock child rows and the new
/Request/tv/{id}/child fixture now match the real wire shape.
2026-09-19 16:16:54 +01:00
gronod 40155b5981 Resolve recent TV rows to real tv_parent request ids
Closes #11. Ombi builds recentlyRequested TV rows from child requests,
so requestId is a child id — and upstream persists the provider id as
the child PK for new-request children, so the value is provider-shaped
(TVDB/TMDB) while the parent request id never appears in the payload.
Consumers following target into read_requests get hit upstream 500s.

TV rows now resolve through one bounded v1 parent scan: an exact
child-id match against embedded childRequests (authoritative), then a
provider-id fallback against tvDbId/externalProviderId covering
versions that put the provider id in requestId directly. The child id
is preserved as an ombi_tv_child identifier, provider ids land under
tvdb/tmdb/imdb, and unresolvable rows emit target.id 0 with a warning
rather than a fabricated or provider-shaped target.
2026-09-19 16:15:18 +01:00
gronod 012f6c0852 Decode the v1 TV parent list's RequestsViewModel wrapper
Live Ombi serves GET /api/v1/Request/tv/{count}/{pos}/1/0/0 as
{"collection":[...],"total":N}, not a bare array, so decodeArray made
every bounded parent scan fail on real instances — the #7 details
overlay and the #10 search fallback only worked against the mock's
unrealistic bare-array fixture. Decode the documented wrapper shape
(tolerating bare arrays) and fix the mock to serve the real shape so
tests exercise what production sees.
2026-09-19 16:04:33 +01:00
gronod 6ebcb2fa08 Fix gofmt violations breaking the CI formatting check
Build and publish / Test and build (darwin) (push) Successful in 2m23s
Build and publish / Test and build (linux) (push) Successful in 2m45s
Build and publish / Test and build (windows) (push) Successful in 3m11s
Build and publish / Build and publish Docker image (push) Successful in 2m25s
Blank lines with trailing whitespace in the #10 TV search fallback and
bounded-scan helpers caused every Test and build matrix job to fail at
Check formatting, masking all downstream steps including Docker publish.
2026-09-19 14:52:50 +01:00
gronod 35723bf3ea Update docs with M6 findings, specification harmonisation, and verification notes
Build and publish / Test and build (linux) (pull_request) Failing after 35s
Build and publish / Test and build (linux) (push) Failing after 32s
Build and publish / Test and build (darwin) (pull_request) Failing after 1m11s
Build and publish / Test and build (darwin) (push) Failing after 1m14s
Build and publish / Test and build (windows) (pull_request) Failing after 2m50s
Build and publish / Build and publish Docker image (pull_request) Skipped
Build and publish / Test and build (windows) (push) Failing after 2m51s
Build and publish / Build and publish Docker image (push) Skipped
2026-09-19 12:53:25 +01:00
gronod 951db61ae7 Fix #18: Expose saved-server identity leaves in settings projections to allow media server discovery 2026-09-19 12:51:32 +01:00
gronod 0a32b2a187 Fix #16: Support Plex servers object wrapper and fix sibling option decode paths 2026-09-19 12:47:51 +01:00
gronod 16b0161d73 Fix #17: Add per-category reference key tables for root folders and service options 2026-09-19 12:45:30 +01:00
gronod 63ef67c8b7 Fix #20: Make reference projection value deterministic and guard against mojibake labels 2026-09-19 12:44:30 +01:00
gronod a039ce0276 Update docs with M5 findings and verification
Build and publish / Test and build (linux) (push) Failing after 33s
Build and publish / Test and build (darwin) (push) Failing after 1m4s
Build and publish / Test and build (windows) (push) Failing after 2m48s
Build and publish / Build and publish Docker image (push) Skipped
2026-09-19 10:42:17 +01:00
gronod 7cf1ece63c Surface upstream error detail on 5xx responses 2026-09-19 10:42:14 +01:00
gronod f763d7fdb3 Fix #10: Add TV search fallback using bounded parent scan 2026-09-19 10:42:11 +01:00
gronod 5e4f8d5d2b Fix #7: Overlay TV request state for TVDB using bounded scan 2026-09-19 10:42:08 +01:00
gronod 019caca1b3 Fix M4 discover browse and search contracts
Build and publish / Test and build (darwin) (pull_request) Successful in 1m54s
Build and publish / Test and build (linux) (pull_request) Successful in 2m14s
Build and publish / Test and build (windows) (pull_request) Successful in 3m6s
Build and publish / Build and publish Docker image (pull_request) Successful in 1m54s
Build and publish / Test and build (darwin) (push) Successful in 1m44s
Build and publish / Test and build (windows) (push) Successful in 3m4s
Build and publish / Test and build (linux) (push) Successful in 2m4s
Build and publish / Build and publish Docker image (push) Successful in 1m58s
2026-09-19 10:07:10 +01:00
gronod b6e1822291 Fix identity projection so discover/search/recent results stay callable.
Build and publish / Test and build (darwin) (pull_request) Successful in 1m58s
Build and publish / Test and build (linux) (pull_request) Successful in 2m42s
Build and publish / Test and build (windows) (pull_request) Successful in 3m5s
Build and publish / Build and publish Docker image (pull_request) Successful in 2m31s
Build and publish / Test and build (darwin) (push) Successful in 1m58s
Build and publish / Test and build (linux) (push) Successful in 2m51s
Build and publish / Test and build (windows) (push) Successful in 3m4s
Build and publish / Build and publish Docker image (push) Successful in 2m25s
Movie and TV details were dropping or duplicating identifiers (missing
tmdb, doubled imdb, collection id treated as the movie, v2 seriesId
labelled tvmaze). v2 browse and collection members only populate `id`,
so those lists arrived with identifiers: []. Multi-search emitted
media=unknown for capitalised Artist. Recent TV items put a provider id
in target.id, so follow-up get calls failed.

Label identifiers by origin route, fall back to `id` in that same
namespace, emit seriesId as tvmaze only on v1 TVMaze routes, map
mediaType case-insensitively, and prefer requestId for request targets.

Closes #6, #2, #9, #11
2026-09-19 09:25:00 +01:00
gronod e83d9739b9 Remove original schema documentation files
Build and publish / Test and build (darwin) (push) Successful in 2m5s
Build and publish / Test and build (linux) (push) Successful in 2m51s
Build and publish / Test and build (windows) (push) Successful in 3m12s
Build and publish / Build and publish Docker image (push) Successful in 2m21s
The schema-orig directory contained assessment, authentication, tool mapping, input schemas, and endpoint coverage documentation that has been superseded by the current implementation. These files were design artifacts from an earlier phase and are no longer needed.
2026-09-19 09:12:05 +01:00
36 changed files with 3204 additions and 8962 deletions
+1 -1
View File
@@ -115,7 +115,7 @@ Tools are grouped into **bundles** — deployment policy groups, not permission
| `read_votes` | Global vote list or votes on a request. |
| `read_users` | Self, authorized user lookup, claims, online users, preference read. |
| `read_library` | Recent additions, calendar, artwork. |
| `read_server` | Server status, version, features, news, stats, cron validation. |
| `read_server` | Server status, version, features, stats, cron validation. |
| `read_integration` | Saved ARR options and authorized media-server metadata. |
| `write_request_create` | Create one media request or an explicit collection request. |
| `write_request_subscribe` | Subscribe/unsubscribe to a request. |
-80
View File
@@ -1,80 +0,0 @@
# Assessment of the two existing designs
## Verdict
`schema-swe` is the stronger **API inventory starting point**: it surveys administration, integrations, discovery and auxiliary routes much more broadly. `schema-swe-med` is the stronger **default product scope starting point**: it keeps most instance administration out of everyday media workflows and separates moderation, structural changes and subscriptions. Neither is a complete, internally consistent implementation contract. Both need routing corrections, complete schemas, explicit result contracts and a firmer distinction between documented facts and inferred semantics.
The assessment below is relative to the checked-in RAML, not an assertion about every Ombi release. No live credentials, source controller implementations or deployed behaviour were inspected.
## Comparable criteria
| Criterion | schema-swe | schema-swe-med |
|---|---|---|
| Declared scope | 22 tools, 12 reads and 10 writes; broad administration included | 19 tools, 13 reads and 6 writes; administration mostly excluded |
| Coverage accounting | Calls 321 paths “endpoints”; actual total is 377 method/path operations. Its exactly-once coverage claim is not demonstrated by a ledger | Approximate “~180 endpoints” without an enumerated covered set; cannot substantiate the number |
| Consolidation | Good use of media/action discriminators; several tools combine unrelated privileges and very different inputs | Better separation of request moderation and subscriptions; still several ambiguous parameter bags |
| Routing fidelity | Generally broader and more accurate, but several generalized routes imply nonexistent variants | Multiple definite method/path/body errors in core workflows |
| Schema completeness | Three input schemas for 22 tools; no output schemas or annotations | Three input schemas for 19 tools; no output schemas or annotations |
| Input validation | Some conditional requirements; open objects and a conditional-default bug | Closed objects are an improvement; claimed conditional/exclusive schemas are not actually present |
| Authentication | Useful token caching, expiry margin and single-flight design; changes project API-key-first premise | Similar lifecycle proposal; contradictory required credentials and unsupported refresh-token discussion |
| Secret handling | Redaction is recognized, but some tool inputs/operations contradict the no-credentials promise | Smaller surface reduces exposure, but forwarding arbitrary ProblemDetails and user entities remains unsafe |
| Result usability | Mostly raw entities; inconsistent variants, recursive graphs and secret-bearing nested users | `{total, items}` normalization is a good direction; totals and cross-version equivalence are overpromised |
| Implementation readiness | Design sketch with substantial useful inventory | Design sketch with useful scope decisions but more core correctness fixes needed |
These are qualitative findings, not arbitrary numerical scores. A smaller advertised tool count does not measure coverage or usability: the size and ambiguity of each tool's argument space matter too.
## What schema-swe gets right
It recognizes that HTTP method alone does not determine side effects: the newsletter unsubscribe GET mutates state, while metadata lookup POSTs may be read operations. It includes useful details easily missed in a media-only inventory: the `availble` spelling, integration options, per-user preferences, auxiliary images, jobs, features and administrative settings. The TV request schema distinguishes the v1 TVDB and v2 TMDB request bodies correctly. Keeping authentication internal and deduplicating token acquisition are sensible ideas if JWT support is later verified and enabled.
## What schema-swe should change
1. **Correct the denominator and coverage claim.** `api.raml` has 321 resource keys but 377 operations. Broad brace/wildcard tables obscure whether each operation really has a usable branch. `read_requests` includes count, totals and `userhasrequest`, but its schema has no distinct action or user argument to select all of them. `write_request_manage` also has action-name drift between its table and schema.
2. **Remove invalid Cartesian products.** There is no `/api/v2/Requests/album/unavailable/...`. Album status options must differ from movie/TV. Nonpaged seasonal/requested movie discovery routes are also absent. Expanding braces mechanically would invent routes.
3. **Fix TV IDs.** Its lifecycle schema describes `requestId` as a TV parent ID for all actions, while moderation requires an explicitly established child target. The RAML v2 TV listing response is `RequestsViewModel<ChildRequests>`, whereas v1 TV lists return parents. This is a material distinction, not a presentation detail. The child interpretation of moderation `id` still needs controller verification: its RAML model only calls the field `id`.
4. **Do not send unsupported request properties.** `MovieRequestViewModel` supports `is4kRequest`; TV creation models do not. Album creation contains only `foreignAlbumId` and `requestedByAlias`; shared on-behalf/root/quality arguments cannot be forwarded to albums. Collection creation has no documented body at all. `requestOnBehalf` is a string without a documented username-versus-ID interpretation, so the assertion “Username” is unproven.
5. **Fix JSON Schema semantics.** In `read_requests`, `scope` is optional and defaults to `list`, but the `if` clauses test `properties.scope` without requiring its presence. With `scope` absent, all conditions match; `requestId`, `query` and `mediaType` become required. JSON Schema `default` does not insert a value. `tvSelection.mode` is not required, contradictory identifiers pass `anyOf`, extra fields are accepted, and `update` does not schema-require `payload`. These are enforceable constraints, not limitations of JSON Schema.
6. **Reconcile secret promises with the catalogue.** `write_user.user.password`, integration connection overrides, CouchPotato API-key acquisition and Plex sign-in conflict with the promise that the model never handles credentials. Settings read-redact-write is not safe unless omitted secrets are preserved internally; sending redaction placeholders back could corrupt configuration. Nested `OmbiUser` has `userAccessToken` and `mediaServerToken`, so redacting settings alone is insufficient.
7. **Avoid unrestricted full-entity writes.** `payload: object` exposes server-owned identity, status and recursive relationships. Bind mutations to a small typed target and a defined set of editable fields. Do not combine `deleteAll` with routine subscriptions under one tool; description-only confirmation is not authorization.
8. **Do not invent enum semantics.** RAML documents numeric sets for `RequestType`, `IssueStatus`, `RequestSource` and `VoteType`, not their labels. Calling issue value 3 “reserved” is no better evidenced than calling it “closed”. Preserve numeric evidence and verify labels.
9. **Do not reject album reprocessing as an absent route.** The generic `/api/v2/Requests/reprocess/{type}/{requestId}/{is4K}` exists with a three-value RequestType. Whether every media kind is operationally supported is a separate verification question.
10. **Specify actual return variants.** Deletes sometimes return `RequestEngineResult`, others document only success. Tester routes differ between Boolean and `TesterResultModel` or unspecified bodies. Image routes do not uniformly document URL responses. A generic promise of one upstream shape is inaccurate.
There are also smaller completeness issues: discovery is overloaded into a search tool whose query is always required; keyword and provider searches do not clearly map their `searchTerm` query; Stats omits `from`/`to`; and the settings notes say 12 notification sections while listing 13. These matter less than identifier/body correctness, but reinforce why the exact-operation ledger and one authoritative schema per tool are necessary.
## What schema-swe-med gets right
The explicit exclusion of most administration is a defensible product choice, not a coverage defect by itself. Splitting request creation, moderation, management and subscriptions is easier to authorize and explain. It calls out multi-search TMDB IDs for TV and the need for TV child IDs. Closed nested objects reduce accidental parameters. Compact TV results and a normalized list envelope are useful goals. Avoiding unbounded delete-all is reasonable.
## What schema-swe-med should change
| Finding | Existing claim | RAML evidence / correction |
|---|---|---|
| Similar movies | `GET /api/v2/Search/movie/similar` | **POST**, with `SimilarMoviesRefineModel` |
| Actor search | `GET /api/v1/Search/movie/actor` | **POST**, with `SearchActorModel` |
| Album denial | POST music deny | **PUT** `/api/v1/request/music/deny` |
| User detail | `GET /api/v1/Identity/{userId}` | GET `/api/v1/Identity/User/{id}`; the other path is DELETE only |
| Retry trigger | `POST /api/v1/RequestRetry` | Absent; only GET queue and DELETE queue entry are documented |
| Lidarr metadata | `GET /api/v1/Lidarr/Metadata` | **POST** with Lidarr settings; saved configuration must be loaded internally |
| TV creation | Required `tv.tvDbId` sent to v2 | v2 body uses `theMovieDbId`; `tvDbId` belongs to v1 |
| Album request modifiers | Shared on-behalf/root/profile fields | Absent from `MusicAlbumRequestViewModel` |
| Single album request | Music list endpoint presented as single lookup | No dedicated single-album-request GET; do not pretend a list is a detail route |
| Album unavailable list | Generic v2 status expansion | Absent; reject or provide explicitly verified local filtering, never silently drop filter |
| Extras contract | `id` plus extras allegedly covers ratings/cast/keywords | Ratings needs name/year; keyword detail needs keyword ID; no standalone cast route is mapped |
| Discovery contract | recentlyAdded and collection in mapping | Not in the advertised category list; collection ID absent from parameter list |
| Image contract | TVDB and TMDB image paths | No `idType` to choose them; banner supported only for movies |
| Request management | Parent delete and child delete/update | No discriminator to select parent versus child |
| Issue status | Four labels in mapping, three in schema | Numeric meanings unverified; `closed` omitted from schema despite mapping claim |
| Search sorting | `requestedDate`/other enum values declared valid | v2 description only gives `requestDate` as example; translation is unspecified |
Its schemas state that exclusivity is encoded using `allOf`/`if`/`then`, but contain none of those constraints. For example, `{mediaType:"movie"}` and `{action:"create"}` satisfy their schemas without the necessary bodies. The prose does promise server-side checks, which is better than no validation, but it is misleading to call these exact, fully constrained schemas. Precedence rules for `requestId` versus `search` silently discard user intent when both are supplied.
The claim that all v1/v2 request responses can be normalized to a truthful `{total, items}` ignores arrays with no total, TV parent/child differences and missing filter mappings. The suggested startup probe cannot establish every route and semantic equivalence. Auth docs list username/password as required while also promising key-only operation; the token model has no refresh-token field.
## Shared gaps and the alternative
Both need the project's `ombi_` prefix. Their `read_`/`write_` names are legal MCP names, but names are not MCP annotations or permission checks. Neither specifies full output schemas, tool-level side-effect hints, token budgets, partial failures, bounded pagination, capabilities, unknown fields, resource handling or errors inside HTTP 200 responses.
The alternative uses closed, disjoint action branches; explicit media/provider/request namespaces; documented method/path routing; typed response projections; and one inventory row per HTTP operation. It keeps the narrower design's default focus and the broader design's inventory breadth, while placing administration behind a separate deployment policy. It records unsupported operations honestly instead of forcing them into misleading catch-all tools.
MCP allows explicit draft-07 schemas; the first draft's use of draft-07 is **not itself a standards violation**. This proposal chooses 2020-12 for consistency. Output schemas and annotations are protocol options that this design adopts for usability, not retroactive mandatory requirements the drafts violated. See the [MCP tools specification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools).
-35
View File
@@ -1,35 +0,0 @@
# Authentication and authorization
## Upstream configuration
`OMBI_URL` and `OMBI_API_KEY` are required in the baseline deployment. Send the key in the `ApiKey` header as documented by [ApiKeyAuth](../api/raml/securitySchemes/api_key.raml). Do not read a runtime base URL from the RAML. Preserve a configured base-path prefix when appending API routes; simply resolving an absolute `/api/...` URL against a prefixed base can discard that prefix.
Only deployment configuration chooses upstream origins. Reject URLs with embedded credentials, query strings or fragments. Encode every path segment and query argument independently. Use TLS where configured; permit intentionally configured local HTTP instances without silently weakening certificate checks. Do not follow cross-origin redirects with credentials. Saved integration destinations are separately controlled administrator configuration; tool callers cannot override a host, port or arbitrary URL.
The global RAML security declaration does not describe the effective Ombi identity, per-operation permissions or anonymous exceptions. API-key possession must not be equated with a human user, unlimited admin privilege or a particular quota principal. Resolve and verify the configured principal and each supported workflow; fail a user-scoped operation if a usable upstream identity cannot be established. Do not invent user impersonation headers.
## Optional JWT adapter
JWT is a possible extension, not a prerequisite replacing project configuration. If introduced, explicitly select `api_key` or `user_jwt` in deployment configuration. A selected JWT mode requires username/password from a secret store or environment; partial or ambiguous credentials fail startup. Never switch from rejected JWT credentials to a potentially more privileged API key automatically.
The token endpoint and `UserAuthModel`/`Token` types are documented; accepting Bearer tokens on every route, bypassing the global ApiKey security declaration for login, and permission semantics require verification. The token response has `access_token` and `expiration`, not a refresh token. `/Token/refresh` uses `token` and the spelling `userename`; do not silently correct that field or assume a refresh-token protocol.
Once verified: cache tokens in memory; use the earliest valid expiry with a clock-skew margin; deduplicate refresh; avoid indefinite caching when expiry is missing; never log token contents. Decoding JWT `exp` for scheduling is not signature validation. Do not assume `rememberMe: true` changes lifetime without evidence.
Retry a read at most once after a definite authentication rejection and successful credential refresh. A timed-out or disconnected mutation has an unknown outcome and must not be replayed automatically. A write may be retried after 401 only when the adapter establishes the request was rejected before execution. Never rotate credentials, submit requests, send notifications or run jobs during capability probing.
## MCP client authorization is separate
For a local stdio deployment, use process/environment isolation. For a protected HTTP deployment, implement MCP transport authorization independently from Ombi credentials, including token audience validation and protected-resource discovery. Never accept an arbitrary client's Ombi token as the MCP server's bearer token or pass MCP access tokens through to Ombi. These are different trust boundaries. See [MCP authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) and [security guidance](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices).
A single configured Ombi principal means all authorized clients share that principal's upstream power. Multi-user hosting needs an explicit client-to-principal binding and isolated credentials/caches. Do not claim user isolation merely because `requestOnBehalf` exists.
## Policy and secret boundaries
Use three proposed deployment bundles: `core`, `moderation`, and `administration`. These are server policy names, not Ombi claims or MCP-standard scopes. Authorize every call and branch even if it was advertised earlier. Remove unavailable branches from advertised schemas where practical; otherwise return a precise capability error. Denial is never an invitation to retry with administrator credentials.
Credentials and authentication endpoints are not tools. Administrative settings mutations accept typed non-secret patches only; credential replacement and destination changes remain in the administrator's UI. `settings_read` returns an allowlisted projection and an opaque revision token, not a redacted full object intended for blind round-tripping. The server preserves private fields internally while merging a patch. If the server cannot read and preserve the original full section safely, saving that section is unsupported.
No global “confirm” Boolean is treated as authority. Clients can show an operation for approval using their normal MCP interaction flow; server policy enforces the operation's actual scope. Large collection requests, email recipients, destructive deletes and job effects must be visible before execution. This is a product interaction rule, not a requirement to interrupt this documentation task.
Apply output allowlists recursively: user entities, issue comments, requests, stats, integration responses and exception bodies can contain credentials or private data. Never forward raw headers, stack traces, arbitrary ProblemDetails extensions, signed query strings, webhook URLs or token-bearing media links. Audit sanitized operation identity, target, outcome and correlation ID; do not audit credentials or full raw bodies.
-149
View File
@@ -1,149 +0,0 @@
# Tool catalogue and routing
## Grouping strategy
There are **31 defined tools: 18 core, 5 moderation, 8 administration**. Core is the ordinary media workflow; it is not a guarantee that every core branch is authorized for every Ombi user. User listing, retry queue inspection, saved integration server/user details, cron validation and another user's preferences require additional branch authorization. The administration and moderation bundles are opt-in. A read-only deployment can advertise just the 12 core read tools, with privileged branches removed.
Grouping follows a task and side-effect boundary: searching, browsing, reading details, submitting, moderating, deleting and subscribing are distinct. Movie/TV/album variants belong together when their intention is the same. Settings sections share one schema-driven administration tool; credentials, raw HTTP, arbitrary URLs, full entity edits and unbounded bulk deletes are not tools. This balances discoverability against overly large action enums.
The [input catalogue](03-input-schemas.md) defines every parameter, required field, enum and structural constraint. The [operation ledger](05-endpoint-coverage.md) supplies exact method/path pairs, wire parameters and upstream body/result types. Together with the rules below, these form the routing contract. There is no undocumented fallback precedence or generic endpoint passthrough.
## Catalogue
| Tool | Task | Bundle | Result family |
|---|---|---|---|
| `ombi_search` | Text, multi, movie refinement, actor search | core | media page |
| `ombi_discover` | Curated lists, similar movies, collection, credits, artist albums, advanced movie filters | core | media page |
| `ombi_media` | Details by explicit provider or request; ratings; streaming | core | media/details page |
| `ombi_reference` | Genres, languages, keywords, watch-provider catalogue, countries, issue categories | core | reference page |
| `ombi_requests` | List/get/search, TV children, recent requests, privileged retry queue | core | request page / retry page |
| `ombi_request_stats` | Counts, totals, per-media quota, user-has-requests | core | metrics |
| `ombi_issues` | Issues, grouped summaries, comments and counts | core | issue/comment/group page or metrics |
| `ombi_votes` | Global vote list or votes on a request | core | vote page |
| `ombi_users` | Self, authorized user lookup, claims, online users, preference read | core | user/reference page |
| `ombi_library` | Recent additions, calendar, artwork | core | media/calendar/artwork page |
| `ombi_server` | Status, version, features, news, stats, cron validation | core | metrics/reference page |
| `ombi_integration_read` | Saved ARR options and authorized media-server metadata | core | reference/user page |
| `ombi_request_create` | One media request or explicit collection request | core | mutation |
| `ombi_request_subscribe` | Subscribe/unsubscribe | core | mutation |
| `ombi_issue_create` | Report an issue | core | mutation |
| `ombi_issue_comment` | Add a comment | core | mutation |
| `ombi_vote` | Up/down vote | core | mutation |
| `ombi_user_preferences` | Language, streaming country, newsletter opt-out | core | mutation |
| `ombi_request_moderate` | Approval, denial, availability | moderation | mutation |
| `ombi_request_delete` | Explicit single movie/album/TV-parent/TV-child deletion | moderation | mutation |
| `ombi_request_options` | Advanced routing overrides and TV root/quality | moderation | mutation |
| `ombi_request_reprocess` | Reprocess an existing request | moderation | mutation |
| `ombi_issue_manage` | State, deletes, category management | moderation | mutation |
| `ombi_settings_read` | Safe configuration projection and revision | administration | settings |
| `ombi_settings_write` | Typed non-secret patch or feature flag | administration | mutation |
| `ombi_user_manage` | Delete user or send welcome email | administration | mutation |
| `ombi_integration_test` | Test a saved profile; can send notifications | administration | mutation |
| `ombi_job_run` | Trigger permitted jobs / watchlist revalidation | administration | mutation |
| `ombi_notification_send` | Email explicit recipients | administration | mutation |
| `ombi_retry_remove` | Remove one queue entry | administration | mutation |
| `ombi_logs` | Bounded, sanitized diagnostic reads | administration | log page |
## Shared routing and input rules
1. Reject unknown properties and irrelevant branch arguments. Required `action`, `media`, `provider`, `kind` and IDs cannot be supplied by silent defaults. Optional page values default server-side to offset 0 / limit 25; maximum limit is 100. Schema defaults are documentation, not mutation of the input.
2. Upstream spelling and case are literal. `request/music`, `Request/movie`, `Requests/album`, `NotificationPreferences` and `notificationpreferences` are distinct contract spellings. Encode values; do not change literal paths to match public naming conventions.
3. The public media names are movie, TV, artist and album. “Music” is an upstream route segment or the multi-search filter, not a universal alias. No artist-request operation exists. External IDs are never interchangeable with Ombi request IDs. Music IDs are opaque nonempty provider strings until a stronger MusicBrainz format is verified.
4. `RequestType`, `IssueStatus` and other integer enums have no labels in the supplied RAML. Numeric-code inputs are deliberately retained only where routing requires them and semantics are otherwise unproven. Publish a verified label map later rather than inventing it now. Search response IDs must retain their source/provider namespace, including multi-search TV TMDB IDs.
5. A TV parent is a show record; a TV child is an individual request under it. `ombi_requests.list(media=tv)` uses v2 child pages. Parent detail and children enumeration use v1. A compact projection of v2 children is preferable to secretly swapping in `tvlite` parent results. Return `parent_request_id` alongside child IDs when present.
6. Parameter names such as `currentPosition`, `position`, `skip`, `count`, `take` and `amountToLoad` are mapped exactly per ledger. Offsets are zero-based by this MCP contract; the adapter must verify ambiguous upstream paging behaviour. Requests use count then position; issue summary uses position then take; issue list uses take then skip. Do not reverse these pairs.
7. Array responses without server pagination are sliced locally only within a bounded fetched response. Mark pagination as local and total unknown unless the complete collection was obtained. A result-size cap is a truncation warning, not a fabricated server total or a promise that the next page exists.
8. `format: date-time` and cross-field comparisons must be enforced by the server, not assumed from a client's validator. Stats requires `from <= to` when both are supplied. Strings must contain non-whitespace text where used as queries/comments. Reject duplicate season numbers and duplicate episode numbers; impose a maximum of 2,000 selected episodes per call in addition to per-array limits.
9. Never infer permissions solely from the fact that a route is in RAML. Verify capability/role at call time, enforce local policy and retain upstream denial. Per-tool annotations are conservative: all mutations are non-idempotent until side effects are verified; all tools have `openWorldHint: true` because they interact with a configured external service.
## Search, discovery and details
`ombi_search.text` routes to the matching v1 `Search/movie`, `Search/tv`, `Search/music/artist`, or `Search/music/album` search-term route. It does not fan out implicitly. `multi` POSTs to v2 multi search; map `tv_shows` to `tvShows` and include all four Boolean properties explicitly, false for categories not selected. `movie_refine` POSTs `{searchTerm, year?, languageCode?}`. `actor` POSTs `{searchTerm, languageCode?}` to v1 movie/actor. Local `page` is never forwarded as an undocumented query parameter.
`ombi_discover.browse` uses paged v2 routes. Map `now_playing→nowplaying`, `top_rated→toprated`, `most_watched→mostwatched`. Movie allows popular/now-playing/top-rated/upcoming/seasonal/requested; TV allows popular/anticipated/most-watched/trending/requested. No other cross-product is valid. Nonpaged v1 and v2 equivalents are compatibility routes only.
`similar` is POST v2 movie/similar with `{theMovieDbId, languageCode?}`. A verified v1 POST equivalent can preserve language; a v1 GET cannot preserve a language argument. `collection` returns the collection's members/basic metadata, without creating requests. `credits` chooses actor/{actorId}/movie or /tv. `artist_albums` uses v1 music/artist/album/{foreignArtistId}.
`advanced_movie` POSTs the exact DiscoverModel property names: `release_year→releaseYear`, `genre_ids→genreIds`, `keyword_ids→keywordIds`, `watch_provider_ids→watchProviders`, `company_ids→companies`, and decade unchanged. Do not invent a `query` requirement for discovery. The optional upstream `type` field is omitted until its semantics are verified; release year and decade must agree if both supplied.
`ombi_media.details` chooses v2 movie TMDB/IMDb, TV TVDB/TMDB, artist, or artist/album routes. IMDb path placeholder spelling differs from its parameter declaration; substitute the actual path placeholder. `by_request` chooses movie/request, tv/request or artist/request; the TV namespace of this particular upstream route requires adapter verification and must not be guessed from list results. `movie_localized` uses POST v1 movie/info with `{theMovieDbId, languageCode}`. `ratings` uses title and year, not a numeric media ID. `streaming` uses TMDB even for TV. Cast/crew are projections of detailed metadata where present, not invented standalone endpoints.
`ombi_reference.keywords` passes query `searchTerm`; keyword detail uses its own keyword ID. Watch-provider catalogue search also has optional `searchTerm`; it is distinct from streaming availability for a particular title. Other branches have no request body. Reference values are good optional cached resources, but remain available through tools for tool-only clients.
## Requests and quotas
Use v2 list/status routes, always with sort and page segments. Public `sort.field=request_date` maps to the documented example `requestDate`; no speculative sort fields are published. `all` means the base route, not an `/all/` segment. Album lacks an unavailable-status route, so that combination fails schema validation. The misspelled movie `availble` route is an explicitly gated compatibility alias, not the primary path.
`get` supports movie and TV parent only; no album single-request endpoint is advertised. `children` returns children for a parent. Request `search` uses the appropriate v1 route and rejects list-only status/sort arguments. `recent` uses v2 recentlyRequested. `retry_queue` is a privileged GET and returns queue IDs separately from underlying request IDs.
`ombi_request_stats.counts` uses Request/count; `total` uses the media's total endpoint; `quota` uses its remaining endpoint. Quota belongs to the actual upstream principal. `has_requests` requires an explicit `user_id` by MCP policy and sends it as the optional upstream `userId` query; viewing another user is subject to authorization. Do not combine instance totals with a per-user quota under an unlabeled “total”.
### Creating requests
| Branch | Route | Exact body construction |
|---|---|---|
| movie | POST `/api/v1/Request/movie` | `tmdb_id→theMovieDbId`, `is_4k→is4kRequest`, `language→languageCode`; authorized optional on-behalf/overrides |
| TV, TMDB | POST `/api/v2/Requests/tv` | `id→theMovieDbId`, optional `languageCode`, selection, language profile and overrides |
| TV, TVDB | POST `/api/v1/Request/tv` | `id→tvDbId`, selection, language profile and overrides; no languageCode or 4K field |
| album | POST `/api/v1/request/music` | `musicbrainz_id→foreignAlbumId`, optional `requested_by_alias→requestedByAlias`; no shared overrides |
| collection | POST `/api/v2/Requests/movie/collection/{collectionId}` | No documented body. No 4K, language or on-behalf overrides accepted |
`on_behalf_user_id` is an MCP user-ID contract. Enable only after verifying whether upstream `requestOnBehalf` expects that ID or a username; if username, resolve ID to username internally and explicitly. Translate root/quality overrides to `rootFolderOverride`/`qualityPathOverride`. Never silently ignore unavailable options.
TV selection `all`, `first_season`, `latest_season` sets exactly one of `requestAll`, `firstSeason`, `latestSeason` true and the other two false. `episodes` sets all false and maps seasons to `{seasonNumber, episodes:[{episodeNumber}]}`. Require explicit nonempty episode lists. RAML does not establish that an empty list means a whole season; a caller wanting a full season must read details and select its actual episodes. If a verified adapter later adds a whole-season shortcut, add an explicit branch. Do not fabricate `source` enum labels or send a Plex-watchlist origin from this MCP client.
Collection requests may have partial effects; RAML specifies a single `RequestEngineResult`, not a per-movie result list. Read/display collection membership for review, but do not imply transactional consistency between that read and the later creation. Return accepted/unknown detail where upstream offers no per-item results. Do not simulate collections by silently issuing many movie POSTs.
### Existing request writes
Moderation approve/available/unavailable is POST; deny is PUT for all three media families. Movie body is `{id, is4K}` and deny adds reason. TV/album body is `{id}` and deny adds reason. Require a nonempty denial reason by MCP policy. Movie creation's `is4kRequest` and moderation's `is4K` are deliberately different wire names.
TV moderation IDs are treated as child IDs by the proposed contract, but RAML's bare `id` does not prove controller semantics. Verify before enabling, including subscription and advanced-options target semantics. Do not send a parent ID merely because an integer validates.
Delete routes distinguish movie, album, TV parent and TV child. Parent deletion may affect all associated children and must be described that way. No delete-all branch exists. Subscription has separate movie/TV subscribe/unsubscribe endpoints; album subscriptions are absent.
Advanced options body is `{requestId, rootPathOverride?, qualityOverride?, languageProfile?}`. The schema accepts only these editable fields. Direct TV root/quality PUTs put both IDs in the URL and have no body. Reprocess uses numeric RequestType, request ID and `is4K` path Boolean; enable supported type/ID/variant combinations only after verification. In particular, do not claim that album reprocessing is absent simply because album lacks its own named route.
Full-entity movie/TV/child PUTs are intentionally excluded: their server-owned and recursive fields are not safe public patch contracts. This does not prevent typed routing changes through the documented advanced-options routes.
## Issues, votes and users
Issue `list` uses v1 paged issues and returns individual records. `summary` uses v2 and returns provider-grouped summaries; these are not interchangeable pagination units. Provider details v2 is a summary route; the v1 provider/request routes have unspecified result schemas and must be verified before publishing their projections. Category reads are owned by `ombi_reference`, avoiding a second overlapping tool branch.
Issue creation maps only title, subject, description, `issueCategoryId`, numeric `requestType`, and optional `requestId`/`providerId`. Require at least one association by design; RAML marks these properties optional and does not establish a server requirement. If both are provided, verify they refer to the same media. The server supplies author, timestamps and state; never accept `userReported`, comments, resolved date or persistence ID from the model. Comment POST uses `{comment, issueId}`. Status POST uses `{issueId, status}`. Category POST uses `{value}`. Delete IDs are in the path; no speculative update-category endpoint exists.
Vote reads use `Vote/music/{requestId}` for albums; writes use `Vote/{up|down}/album/{requestId}`. Do not derive both directions from one generic segment rule. Vote toggling/repeated-call behaviour is unverified, hence no idempotence promise.
User GET-by-ID is `/Identity/User/{id}`. `self`, all users, dropdown, claims, online and notification preferences each have their exact separate routes. Respect hide-user settings and upstream visibility; do not disclose hidden requester IDs simply to populate a normalized field.
Language writes send `{lang}`; country writes send `{code}`. Newsletter opt-out is the mutating GET `/Identity/newsletter/unsubscribe/{userId}` and is always a write tool. An explicit user ID prevents accidental interpretation as a query; enforce self-or-authorized-admin access. Preference values can be delivery credentials, so generic notification-preference mutation remains outside the tool surface.
User creation/update/local-profile mutation are intentionally excluded because their RAML bodies mix credentials, claims and internal state. Administrative deletion and welcome email are supported separately. Welcome email loads the existing user's required view-model fields internally; it never asks the model to construct a UserViewModel or password.
## Library, server, settings and integrations
Recent TV grouped and ungrouped routes are separate branches. Calendar has no documented date-range query: local bounding is labeled, not sent as an invented parameter. Movie images use TMDB; TV accepts explicit TVDB or TMDB. TV banners are not documented. Album art uses releasegroupart. The generic poster and background routes take no documented title ID; never describe them as a selected title's poster.
Images may be binary, redirects or URLs depending on the route; RAML leaves several response bodies unspecified. Return a safe resource reference only after validating actual content type/shape and destination. Do not invent a signed URL or expose an `ApiKey` query. Random backgrounds are read-only but their outputs are not deterministic; idempotent read annotations describe side effects, not identical results.
Stats passes optional `from` and `to` query values. `update_check` is GET Job/update; `update_info` is GET Update. Running updates is an explicit administrative job. `cron_validate` POSTs `{expression}` to Settings/testcron and is an administrator-gated read calculation, not a scheduled-job mutation.
`ombi_integration_read` prefers saved-settings GETs. Radarr 4K only applies to profiles/root folders, not tags. Sonarr language profiles uses `/v3/LanguageProfiles`. Lidarr Metadata is POST-only, so load saved Lidarr settings privately and construct its request internally. CouchPotato profile is singular and POST-only. Credential acquisition `/CouchPotato/apikey` is never a tool. RPC POST counterparts accepting settings are compatibility adapters to the same read intent; they do not add caller connection overrides.
Plex library lookup uses a known saved `machine_id`; Emby/Jellyfin info and Library POSTs use an authorized saved `server_id` to resolve the server settings privately. No arbitrary server object or connection destination is accepted. Routes that acquire Plex/Emby/Jellyfin access or provision accounts remain internal/manual because their authentication/side effects are not adequately specified here.
Settings section names and wire types are fully listed in [the registry](07-settings-types.md). Read-only flags and customization content are not accidentally accepted as writable sections. Before saving, privately read the original section, verify the revision, merge a typed patch preserving secrets and omitted fields, and POST the full correct wire model. An incomplete original object means save is unsupported. Redacted strings must never become stored credentials. Feature writes are enable/disable POSTs with `{name, enabled}`.
Integration testers take an administrator-provisioned profile ID. The server selects the exact tester body type, resolves credentials internally and forbids destination overrides. `profile_id` must match the service and authorized instance; it is not an arbitrary filesystem path. Some tests really send messages; completion means the tester returned, not necessarily that a human received a notification.
Job run names exactly match the 14 POST Job routes in the ledger, including the case-sensitive `arrAvailability`. `revalidate_watchlist` separately POSTs Plex/WatchlistUsers/revalidate. Update, clear-media-server-data, auto-delete and newsletter jobs have significant effects; deployments must opt in to each. No synthetic job-status polling API or MCP task handle is claimed.
Mass email maps subject/body/bcc and resolves explicit unique user IDs to the upstream `users` entity list privately. Verify the minimal accepted user fields before enabling; never accept arbitrary OmbiUser JSON. Recipient count is capped at 100; an empty list cannot mean “everyone”. Retry removal only DELETEs its queue ID, and never calls a nonexistent POST RequestRetry.
Logs list returns opaque file IDs bound to a vetted upstream basename. Read accepts that ID, with bounded local line slicing. Reject unknown IDs and traversal; sanitize before caching or returning. If robust sanitization is unavailable, omit logs tools. Raw log downloads and UI logging submission remain excluded.
## Compatibility policy
The ledger's alternative routes are accounted for, not all promised working fallbacks. Use only an adapter verified for the instance/version. A global 404 probe cannot distinguish unsupported routes from hidden or missing resources. Never fall back on 401/403, arbitrary 404, validation failures or ambiguous mutation outcomes. Do not transform TVDB to TMDB by copying an integer; any conversion requires an authoritative lookup and an unambiguous match.
Legacy request filters are integers without documented mappings. No passthrough filter integers are exposed and no “partial availability” mapping is invented. Where v2 is unavailable, a verified unpaged v1 list may be projected and locally bounded with truthful metadata; unavailable filtered semantics produce `UNSUPPORTED_CAPABILITY`. API version selection must not silently change parent/child granularity, counts, language, filters or target identity.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-395
View File
@@ -1,395 +0,0 @@
# Complete endpoint coverage ledger
This inventory enumerates every method directly declared under every resource in the supplied RAML: **377 HTTP operations across 321 paths**. IDs follow source order and are audit references, not API identifiers. There is one disposition per operation, including intentional exclusions. Links point to the resource start; multiple methods can share a resource line.
| Disposition | Meaning | Operations |
|---|---|---|
| D | Direct tool contract; verification/authorization gates still apply | 257 |
| A | Alternative/legacy route accounted for; enabled only by a verified compatibility adapter | 37 |
| P | Partial exposure: typed, sanitized subset or server-owned body construction | 59 |
| I | Internal authentication/provisioning, never a model-callable tool | 12 |
| X | Deliberately excluded; manual administration or a narrower supported operation | 12 |
The body/response columns describe the **upstream** schema, not a promise to pass that object through MCP. “Body unspecified” does not assert an empty response. “Type unspecified” can reflect a RAML generation defect: several `type:` declarations were folded into description text. The mapping contract supplies conservative public types and records semantic gaps. No inferred labels for numeric enums are treated as facts.
## Operations
| ID | Method and literal path | Disposition | Owner / branch | Wire inputs | Success response | Notes |
|---|---|---|---|---|---|---|
| 001 | [GET `/api/v2/Calendar`](../api/raml/api.raml#L11) | D | `ombi_library / calendar` | none documented | 200: array&lt;[CalendarViewModel](../api/raml/types/Ombi.Core.Models.Search.V2.CalendarViewModel.raml)&gt; | — |
| 002 | [POST `/api/v1/CouchPotato/profile`](../api/raml/api.raml#L27) | D | `ombi_integration_read / options:couchpotato/profiles` | body [CouchPotatoSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.CouchPotatoSettings.raml) | 200: [CouchPotatoProfiles](../api/raml/types/Ombi.Api.External.ExternalApis.CouchPotato.Models.CouchPotatoProfiles.raml) | Saved settings constructed privately; singular profile path. |
| 003 | [POST `/api/v1/CouchPotato/apikey`](../api/raml/api.raml#L45) | I | `administrator credential provisioning` | body [CouchPotatoSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.CouchPotatoSettings.raml) | 200: [CouchPotatoApiKey](../api/raml/types/Ombi.Api.External.ExternalApis.CouchPotato.Models.CouchPotatoApiKey.raml) | Acquires a credential; never return API keys. |
| 004 | [GET `/api/v1/CustomPage`](../api/raml/api.raml#L63) | D | `ombi_settings_read / custom_page` | none documented | 200: [CustomPageSettings](../api/raml/types/Ombi.Settings.Settings.Models.CustomPageSettings.raml) | Allowlisted projection; no raw secrets. |
| 005 | [POST `/api/v1/CustomPage`](../api/raml/api.raml#L63) | P | `ombi_settings_write / patch:custom_page` | body [CustomPageSettings](../api/raml/types/Ombi.Settings.Settings.Models.CustomPageSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 006 | [POST `/api/v1/Emby`](../api/raml/api.raml#L93) | I | `media-server provisioning` | body [EmbySettings](../api/raml/types/Ombi.Core.Settings.Models.External.EmbySettings.raml) | 200: [EmbySettings](../api/raml/types/Ombi.Core.Settings.Models.External.EmbySettings.raml) | Credential-bearing settings request/response; behaviour not established by RAML. |
| 007 | [POST `/api/v1/Emby/info`](../api/raml/api.raml#L113) | D | `ombi_integration_read / media_server:emby/info` | body [EmbyServers](../api/raml/types/Ombi.Core.Settings.Models.External.EmbyServers.raml) | 200: [PublicInfo](../api/raml/types/Ombi.Api.External.MediaServers.Emby.Models.PublicInfo.raml) | POST resolves configured server privately. |
| 008 | [GET `/api/v1/Emby/users`](../api/raml/api.raml#L131) | D | `ombi_integration_read / media_server:emby/users` | none documented | 200: array&lt;[UsersViewModel](../api/raml/types/Ombi.Models.External.UsersViewModel.raml)&gt; | — |
| 009 | [POST `/api/v1/Emby/Library`](../api/raml/api.raml#L143) | D | `ombi_integration_read / media_server:emby/libraries` | body [EmbyServers](../api/raml/types/Ombi.Core.Settings.Models.External.EmbyServers.raml) | 200: [EmbyItemContainer](../api/raml/types/Ombi.Api.External.MediaServers.Emby.Models.EmbyItemContainer_1__Ombi.Api.External.MediaServers.Emby.Models.Media.MediaFolders__Ombi.Api.External__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | POST resolves configured server privately. |
| 010 | [GET `/api/v2/Features`](../api/raml/api.raml#L161) | D | `ombi_server / features` | none documented | 200: array&lt;[FeatureEnablement](../api/raml/types/Ombi.Settings.Settings.Models.FeatureEnablement.raml)&gt; | — |
| 011 | [POST `/api/v2/Features/enable`](../api/raml/api.raml#L177) | P | `ombi_settings_write / feature` | body [FeatureEnablement](../api/raml/types/Ombi.Settings.Settings.Models.FeatureEnablement.raml) | 200: array&lt;[FeatureEnablement](../api/raml/types/Ombi.Settings.Settings.Models.FeatureEnablement.raml)&gt; | Select enable/disable from enabled Boolean. |
| 012 | [POST `/api/v2/Features/disable`](../api/raml/api.raml#L202) | P | `ombi_settings_write / feature` | body [FeatureEnablement](../api/raml/types/Ombi.Settings.Settings.Models.FeatureEnablement.raml) | 200: array&lt;[FeatureEnablement](../api/raml/types/Ombi.Settings.Settings.Models.FeatureEnablement.raml)&gt; | Select enable/disable from enabled Boolean. |
| 013 | [GET `/api/v2/Hub/Users`](../api/raml/api.raml#L227) | D | `ombi_users / online` | none documented | 200: array&lt;[ConnectedUsersViewModel](../api/raml/types/Ombi.Models.ConnectedUsersViewModel.raml)&gt; | Privileged visibility rules apply. |
| 014 | [GET `/api/v1/Identity/Users`](../api/raml/api.raml#L245) | D | `ombi_users / list` | none documented | 200: array&lt;[UserViewModel](../api/raml/types/Ombi.Core.Models.UI.UserViewModel.raml)&gt; | — |
| 015 | [GET `/api/v1/Identity/dropdown/Users`](../api/raml/api.raml#L257) | D | `ombi_users / dropdown` | none documented | 200: array&lt;[UserViewModelDropdown](../api/raml/types/Ombi.Core.Models.UI.UserViewModelDropdown.raml)&gt; | — |
| 016 | [GET `/api/v1/Identity`](../api/raml/api.raml#L269) | D | `ombi_users / self` | none documented | 200: [UserViewModel](../api/raml/types/Ombi.Core.Models.UI.UserViewModel.raml) | — |
| 017 | [POST `/api/v1/Identity`](../api/raml/api.raml#L269) | X | `manual user/profile administration` | body [UserViewModel](../api/raml/types/Ombi.Core.Models.UI.UserViewModel.raml) | 200: [IdentityResult](../api/raml/types/Ombi.Models.Identity.IdentityResult.raml) | Mixed credentials/claims/full entities or delivery-secret preference values are not tool inputs. |
| 018 | [PUT `/api/v1/Identity`](../api/raml/api.raml#L269) | X | `manual user/profile administration` | body [UserViewModel](../api/raml/types/Ombi.Core.Models.UI.UserViewModel.raml) | 200: [IdentityResult](../api/raml/types/Ombi.Models.Identity.IdentityResult.raml) | Mixed credentials/claims/full entities or delivery-secret preference values are not tool inputs. |
| 019 | [POST `/api/v1/Identity/language`](../api/raml/api.raml#L318) | D | `ombi_user_preferences / language` | body [UserLanguage](../api/raml/types/Ombi.Models.Identity.UserLanguage.raml) | 200: body unspecified | Body lang, not language. |
| 020 | [GET `/api/v1/Identity/streamingcountry`](../api/raml/api.raml#L335) | D | `ombi_reference / streaming_countries` | none documented | 200: body unspecified | — |
| 021 | [POST `/api/v1/Identity/streamingcountry`](../api/raml/api.raml#L335) | D | `ombi_user_preferences / streaming_country` | body [CountryStreamingPreference](../api/raml/types/Ombi.Models.Identity.CountryStreamingPreference.raml) | 200: body unspecified | Body code. |
| 022 | [GET `/api/v1/Identity/User/{id}`](../api/raml/api.raml#L359) | D | `ombi_users / get` | path `id`:string required | 200: [UserViewModel](../api/raml/types/Ombi.Core.Models.UI.UserViewModel.raml) | — |
| 023 | [PUT `/api/v1/Identity/local`](../api/raml/api.raml#L374) | X | `manual user/profile administration` | body [UpdateLocalUserModel](../api/raml/types/Ombi.Models.Identity.UpdateLocalUserModel.raml) | 200: [IdentityResult](../api/raml/types/Ombi.Models.Identity.IdentityResult.raml) | Mixed credentials/claims/full entities or delivery-secret preference values are not tool inputs. |
| 024 | [DELETE `/api/v1/Identity/{userId}`](../api/raml/api.raml#L394) | D | `ombi_user_manage / delete` | path `userId`:unspecified required | 200: [IdentityResult](../api/raml/types/Ombi.Models.Identity.IdentityResult.raml) | — |
| 025 | [GET `/api/v1/Identity/claims`](../api/raml/api.raml#L410) | D | `ombi_users / claims` | none documented | 200: array&lt;[ClaimCheckboxes](../api/raml/types/Ombi.Core.Models.UI.ClaimCheckboxes.raml)&gt; | — |
| 026 | [POST `/api/v1/Identity/welcomeEmail`](../api/raml/api.raml#L422) | P | `ombi_user_manage / welcome_email` | body [UserViewModel](../api/raml/types/Ombi.Core.Models.UI.UserViewModel.raml) | 200: body unspecified | Resolve user view model internally. |
| 027 | [GET `/api/v1/Identity/notificationpreferences`](../api/raml/api.raml#L437) | D | `ombi_users / notification_preferences` | none documented | 200: array&lt;[UserNotificationPreferences](../api/raml/types/Ombi.Store.Entities.UserNotificationPreferences.raml)&gt; | — |
| 028 | [GET `/api/v1/Identity/notificationpreferences/{userId}`](../api/raml/api.raml#L447) | D | `ombi_users / notification_preferences` | path `userId`:string required | 200: array&lt;[UserNotificationPreferences](../api/raml/types/Ombi.Store.Entities.UserNotificationPreferences.raml)&gt; | — |
| 029 | [POST `/api/v1/Identity/NotificationPreferences`](../api/raml/api.raml#L461) | X | `manual user/profile administration` | body array&lt;[AddNotificationPreference](../api/raml/types/Ombi.Models.Identity.AddNotificationPreference.raml)&gt; | success unspecified | Mixed credentials/claims/full entities or delivery-secret preference values are not tool inputs. |
| 030 | [GET `/api/v1/Identity/newsletter/unsubscribe/{userId}`](../api/raml/api.raml#L489) | D | `ombi_user_preferences / unsubscribe_newsletter` | path `userId`:string required | 200: body unspecified | Mutating GET; never a read tool. |
| 031 | [GET `/api/v1/Images/tv/{tvdbid}`](../api/raml/api.raml#L499) | D | `ombi_library / tv_images` | path `tvdbid`:integer required | 200: string | Response representation must be verified; no assumed public URL. |
| 032 | [GET `/api/v1/Images/poster`](../api/raml/api.raml#L512) | D | `ombi_library / default_poster` | none documented | 200: string | Response representation must be verified; no assumed public URL. |
| 033 | [GET `/api/v1/Images/poster/movie/{movieDbId}`](../api/raml/api.raml#L521) | D | `ombi_library / image` | path `movieDbId`:string required | 200: string | Response representation must be verified; no assumed public URL. |
| 034 | [GET `/api/v1/Images/poster/tv/{tvdbid}`](../api/raml/api.raml#L534) | D | `ombi_library / image` | path `tvdbid`:integer required | 200: string | Response representation must be verified; no assumed public URL. |
| 035 | [GET `/api/v1/Images/poster/tv/tmdb/{tmdbId}`](../api/raml/api.raml#L547) | D | `ombi_library / image` | path `tmdbId`:string required | 200: string | Response representation must be verified; no assumed public URL. |
| 036 | [GET `/api/v1/Images/background/movie/{movieDbId}`](../api/raml/api.raml#L560) | D | `ombi_library / image` | path `movieDbId`:string required | 200: string | Response representation must be verified; no assumed public URL. |
| 037 | [GET `/api/v1/Images/banner/movie/{movieDbId}`](../api/raml/api.raml#L573) | D | `ombi_library / image` | path `movieDbId`:string required | 200: string | Response representation must be verified; no assumed public URL. |
| 038 | [GET `/api/v1/Images/background/tv/{tvdbid}`](../api/raml/api.raml#L586) | D | `ombi_library / image` | path `tvdbid`:integer required | 200: string | Response representation must be verified; no assumed public URL. |
| 039 | [GET `/api/v1/Images/background/tv/tmdb/{id}`](../api/raml/api.raml#L599) | D | `ombi_library / image` | path `id`:string required | 200: string | Response representation must be verified; no assumed public URL. |
| 040 | [GET `/api/v1/Images/background`](../api/raml/api.raml#L612) | D | `ombi_library / random_background` | none documented | 200: body unspecified | Response representation must be verified; no assumed public URL. |
| 041 | [GET `/api/v1/Images/background/info`](../api/raml/api.raml#L618) | D | `ombi_library / background_info` | none documented | 200: body unspecified | Response representation must be verified; no assumed public URL. |
| 042 | [GET `/api/v2/Issues/{position}/{take}/{status}`](../api/raml/api.raml#L624) | D | `ombi_issues / summary` | path `position`:integer required; path `take`:integer required; path `status`:[IssueStatus](../api/raml/types/Ombi.Store.Entities.Requests.IssueStatus.raml) required | 200: array&lt;[IssuesSummaryModel](../api/raml/types/Ombi.Core.Engine.V2.IssuesSummaryModel.raml)&gt; | — |
| 043 | [GET `/api/v2/Issues/details/{providerId}`](../api/raml/api.raml#L650) | D | `ombi_issues / provider_summary` | path `providerId`:string required | 200: [IssuesSummaryModel](../api/raml/types/Ombi.Core.Engine.V2.IssuesSummaryModel.raml) | — |
| 044 | [GET `/api/v1/Issues/categories`](../api/raml/api.raml#L667) | D | `ombi_reference / issue_categories` | none documented | 200: array&lt;[IssueCategory](../api/raml/types/Ombi.Store.Entities.Requests.IssueCategory.raml)&gt; | — |
| 045 | [POST `/api/v1/Issues/categories`](../api/raml/api.raml#L667) | D | `ombi_issue_manage / create_category` | body [IssueCategory](../api/raml/types/Ombi.Store.Entities.Requests.IssueCategory.raml) | 200: boolean | — |
| 046 | [DELETE `/api/v1/Issues/categories/{catId}`](../api/raml/api.raml#L698) | D | `ombi_issue_manage / delete_category` | path `catId`:integer required | 200: boolean | — |
| 047 | [GET `/api/v1/Issues`](../api/raml/api.raml#L713) | A | `ombi_issues / list` | none documented | 200: array&lt;[Issues](../api/raml/types/Ombi.Store.Entities.Requests.Issues.raml)&gt; | Unpaged list is compatibility only. |
| 048 | [POST `/api/v1/Issues`](../api/raml/api.raml#L713) | P | `ombi_issue_create` | body [Issues](../api/raml/types/Ombi.Store.Entities.Requests.Issues.raml) | 200: integer | Minimal issue creation projection. |
| 049 | [GET `/api/v1/Issues/{take}/{skip}/{status}`](../api/raml/api.raml#L744) | D | `ombi_issues / list` | path `take`:integer required; path `skip`:integer required; path `status`:[IssueStatus](../api/raml/types/Ombi.Store.Entities.Requests.IssueStatus.raml) required | 200: array&lt;[Issues](../api/raml/types/Ombi.Store.Entities.Requests.Issues.raml)&gt; | — |
| 050 | [GET `/api/v1/Issues/count`](../api/raml/api.raml#L766) | D | `ombi_issues / counts` | none documented | 200: [IssueCountModel](../api/raml/types/Ombi.Models.IssueCountModel.raml) | — |
| 051 | [GET `/api/v1/Issues/{id}`](../api/raml/api.raml#L777) | D | `ombi_issues / get` | path `id`:integer required | 200: [Issues](../api/raml/types/Ombi.Store.Entities.Requests.Issues.raml) | — |
| 052 | [DELETE `/api/v1/Issues/{id}`](../api/raml/api.raml#L777) | D | `ombi_issue_manage / delete` | path `id`:integer required | 200: boolean | — |
| 053 | [GET `/api/v1/Issues/request/{id}`](../api/raml/api.raml#L804) | D | `ombi_issues / by_request` | path `id`:integer required | 200: body unspecified | — |
| 054 | [GET `/api/v1/Issues/provider/{id}`](../api/raml/api.raml#L814) | D | `ombi_issues / by_provider` | path `id`:string required | 200: body unspecified | — |
| 055 | [GET `/api/v1/Issues/{id}/comments`](../api/raml/api.raml#L824) | D | `ombi_issues / comments` | path `id`:integer required | 200: array&lt;[IssueCommentChatViewModel](../api/raml/types/Ombi.Models.IssueCommentChatViewModel.raml)&gt; | — |
| 056 | [POST `/api/v1/Issues/comments`](../api/raml/api.raml#L840) | D | `ombi_issue_comment` | body [NewIssueCommentViewModel](../api/raml/types/Ombi.Models.NewIssueCommentViewModel.raml) | 200: [IssueComments](../api/raml/types/Ombi.Store.Entities.Requests.IssueComments.raml) | — |
| 057 | [DELETE `/api/v1/Issues/comments/{id}`](../api/raml/api.raml#L860) | D | `ombi_issue_manage / delete_comment` | path `id`:integer required | 200: boolean | — |
| 058 | [POST `/api/v1/Issues/status`](../api/raml/api.raml#L875) | D | `ombi_issue_manage / set_status` | body [IssueStateViewModel](../api/raml/types/Ombi.Models.IssueStateViewModel.raml) | 200: boolean | — |
| 059 | [POST `/api/v1/Jellyfin`](../api/raml/api.raml#L893) | I | `media-server provisioning` | body [JellyfinSettings](../api/raml/types/Ombi.Core.Settings.Models.External.JellyfinSettings.raml) | 200: [JellyfinSettings](../api/raml/types/Ombi.Core.Settings.Models.External.JellyfinSettings.raml) | Credential-bearing settings request/response; behaviour not established by RAML. |
| 060 | [POST `/api/v1/Jellyfin/info`](../api/raml/api.raml#L913) | D | `ombi_integration_read / media_server:jellyfin/info` | body [JellyfinServers](../api/raml/types/Ombi.Core.Settings.Models.External.JellyfinServers.raml) | 200: [PublicInfo](../api/raml/types/Ombi.Api.External.MediaServers.Jellyfin.Models.PublicInfo.raml) | POST resolves configured server privately. |
| 061 | [POST `/api/v1/Jellyfin/Library`](../api/raml/api.raml#L931) | D | `ombi_integration_read / media_server:jellyfin/libraries` | body [JellyfinServers](../api/raml/types/Ombi.Core.Settings.Models.External.JellyfinServers.raml) | 200: [JellyfinItemContainer](../api/raml/types/Ombi.Api.External.MediaServers.Jellyfin.Models.JellyfinItemContainer_1__Ombi.Api.External.MediaServers.Jellyfin.Models.MediaFolders__Ombi.Api.External__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | POST resolves configured server privately. |
| 062 | [GET `/api/v1/Jellyfin/users`](../api/raml/api.raml#L949) | D | `ombi_integration_read / media_server:jellyfin/users` | none documented | 200: array&lt;[UsersViewModel](../api/raml/types/Ombi.Models.External.UsersViewModel.raml)&gt; | — |
| 063 | [POST `/api/v1/Job/update`](../api/raml/api.raml#L961) | D | `ombi_job_run / run:update` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 064 | [GET `/api/v1/Job/update`](../api/raml/api.raml#L961) | D | `ombi_server / update_check` | none documented | 200: boolean | — |
| 065 | [POST `/api/v1/Job/plexuserimporter`](../api/raml/api.raml#L982) | D | `ombi_job_run / run:plexuserimporter` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 066 | [POST `/api/v1/Job/plexwatchlist`](../api/raml/api.raml#L993) | D | `ombi_job_run / run:plexwatchlist` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 067 | [POST `/api/v1/Job/embyuserimporter`](../api/raml/api.raml#L1004) | D | `ombi_job_run / run:embyuserimporter` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 068 | [POST `/api/v1/Job/jellyfinuserimporter`](../api/raml/api.raml#L1015) | D | `ombi_job_run / run:jellyfinuserimporter` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 069 | [POST `/api/v1/Job/plexcontentcacher`](../api/raml/api.raml#L1026) | D | `ombi_job_run / run:plexcontentcacher` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 070 | [POST `/api/v1/Job/clearmediaserverdata`](../api/raml/api.raml#L1037) | D | `ombi_job_run / run:clearmediaserverdata` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 071 | [POST `/api/v1/Job/plexrecentlyadded`](../api/raml/api.raml#L1048) | D | `ombi_job_run / run:plexrecentlyadded` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 072 | [POST `/api/v1/Job/embycontentcacher`](../api/raml/api.raml#L1059) | D | `ombi_job_run / run:embycontentcacher` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 073 | [POST `/api/v1/Job/embyrecentlyadded`](../api/raml/api.raml#L1070) | D | `ombi_job_run / run:embyrecentlyadded` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 074 | [POST `/api/v1/Job/jellyfincontentcacher`](../api/raml/api.raml#L1081) | D | `ombi_job_run / run:jellyfincontentcacher` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 075 | [POST `/api/v1/Job/arrAvailability`](../api/raml/api.raml#L1092) | D | `ombi_job_run / run:arrAvailability` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 076 | [POST `/api/v1/Job/autodeleterequests`](../api/raml/api.raml#L1103) | D | `ombi_job_run / run:autodeleterequests` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 077 | [POST `/api/v1/Job/newsletter`](../api/raml/api.raml#L1112) | D | `ombi_job_run / run:newsletter` | none documented | 200: boolean | Explicit per-job policy; job accepted is not job completed. |
| 078 | [GET `/api/v1/LandingPage`](../api/raml/api.raml#L1123) | D | `ombi_server / landing` | none documented | 200: [MediaSeverAvailibilityViewModel](../api/raml/types/Ombi.Models.MediaSeverAvailibilityViewModel.raml) | — |
| 079 | [GET `/api/v1/Lidarr/enabled`](../api/raml/api.raml#L1132) | D | `ombi_integration_read / options:lidarr/enabled` | none documented | 200: boolean | — |
| 080 | [POST `/api/v1/Lidarr/Profiles`](../api/raml/api.raml#L1141) | A | `ombi_integration_read / options:lidarr/profiles` | body [LidarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.LidarrSettings.raml) | 200: array&lt;[LidarrProfile](../api/raml/types/Ombi.Api.External.ExternalApis.Lidarr.Models.LidarrProfile.raml)&gt; | Private saved settings only; no connection overrides. |
| 081 | [GET `/api/v1/Lidarr/Profiles`](../api/raml/api.raml#L1141) | D | `ombi_integration_read / options:lidarr/profiles` | none documented | 200: array&lt;[LidarrProfile](../api/raml/types/Ombi.Api.External.ExternalApis.Lidarr.Models.LidarrProfile.raml)&gt; | — |
| 082 | [POST `/api/v1/Lidarr/RootFolders`](../api/raml/api.raml#L1174) | A | `ombi_integration_read / options:lidarr/root_folders` | body [LidarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.LidarrSettings.raml) | 200: array&lt;[LidarrRootFolder](../api/raml/types/Ombi.Api.External.ExternalApis.Lidarr.Models.LidarrRootFolder.raml)&gt; | Private saved settings only; no connection overrides. |
| 083 | [GET `/api/v1/Lidarr/RootFolders`](../api/raml/api.raml#L1174) | D | `ombi_integration_read / options:lidarr/root_folders` | none documented | 200: array&lt;[LidarrRootFolder](../api/raml/types/Ombi.Api.External.ExternalApis.Lidarr.Models.LidarrRootFolder.raml)&gt; | — |
| 084 | [POST `/api/v1/Lidarr/Metadata`](../api/raml/api.raml#L1207) | D | `ombi_integration_read / options:lidarr/metadata` | body [LidarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.LidarrSettings.raml) | 200: array&lt;[MetadataProfile](../api/raml/types/Ombi.Api.External.ExternalApis.Lidarr.Models.MetadataProfile.raml)&gt; | Private saved settings only; no connection overrides. |
| 085 | [POST `/api/v1/Logging`](../api/raml/api.raml#L1228) | X | `UI-client telemetry` | body [UiLoggingModel](../api/raml/types/Ombi.Models.UiLoggingModel.raml) | 200: body unspecified | No agent-facing log injection tool; MCP logging is a separate protocol facility. |
| 086 | [GET `/api/v1/request/music/{count}/{position}/{orderType}/{statusType}/{availabilityType}`](../api/raml/api.raml#L1243) | A | `ombi_requests / list` | path `count`:unspecified required; path `position`:unspecified required; path `orderType`:unspecified required; path `statusType`:integer required; path `availabilityType`:integer required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.AlbumRequest__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | Legacy parent/array/filter semantics need verified adapter; not equivalent to v2 TV child pages. |
| 087 | [GET `/api/v1/request/music/total`](../api/raml/api.raml#L1273) | D | `ombi_request_stats / total` | none documented | 200: integer | — |
| 088 | [GET `/api/v1/request/music`](../api/raml/api.raml#L1284) | A | `ombi_requests / list` | none documented | 200: array&lt;[AlbumRequest](../api/raml/types/Ombi.Store.Entities.Requests.AlbumRequest.raml)&gt; | Legacy parent/array/filter semantics need verified adapter; not equivalent to v2 TV child pages. |
| 089 | [POST `/api/v1/request/music`](../api/raml/api.raml#L1284) | D | `ombi_request_create / album` | body [MusicAlbumRequestViewModel](../api/raml/types/Ombi.Core.Models.Requests.MusicAlbumRequestViewModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 090 | [GET `/api/v1/request/music/search/{searchTerm}`](../api/raml/api.raml#L1315) | D | `ombi_requests / search` | path `searchTerm`:unspecified required | 200: array&lt;[AlbumRequest](../api/raml/types/Ombi.Store.Entities.Requests.AlbumRequest.raml)&gt; | — |
| 091 | [DELETE `/api/v1/request/music/{requestId}`](../api/raml/api.raml#L1332) | D | `ombi_request_delete` | path `requestId`:unspecified required | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 092 | [POST `/api/v1/request/music/approve`](../api/raml/api.raml#L1348) | D | `ombi_request_moderate / approve` | body [AlbumUpdateModel](../api/raml/types/Ombi.Core.Models.Requests.AlbumUpdateModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 093 | [POST `/api/v1/request/music/available`](../api/raml/api.raml#L1368) | D | `ombi_request_moderate / mark_available` | body [AlbumUpdateModel](../api/raml/types/Ombi.Core.Models.Requests.AlbumUpdateModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 094 | [POST `/api/v1/request/music/unavailable`](../api/raml/api.raml#L1388) | D | `ombi_request_moderate / mark_unavailable` | body [AlbumUpdateModel](../api/raml/types/Ombi.Core.Models.Requests.AlbumUpdateModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 095 | [PUT `/api/v1/request/music/deny`](../api/raml/api.raml#L1408) | D | `ombi_request_moderate / deny` | body [DenyAlbumModel](../api/raml/types/Ombi.Core.Models.Requests.DenyAlbumModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 096 | [GET `/api/v1/request/music/remaining`](../api/raml/api.raml#L1428) | D | `ombi_request_stats / quota` | none documented | 200: [RequestQuotaCountModel](../api/raml/types/Ombi.Core.Models.RequestQuotaCountModel.raml) | — |
| 097 | [POST `/api/v1/Notifications/massemail`](../api/raml/api.raml#L1439) | P | `ombi_notification_send` | body [MassEmailModel](../api/raml/types/Ombi.Core.Models.MassEmailModel.raml) | 200: boolean | Resolve explicit user IDs into minimum verified entity body. |
| 098 | [POST `/api/v1/Plex`](../api/raml/api.raml#L1457) | I | `Plex authentication/provisioning` | body [UserRequest](../api/raml/types/Ombi.Api.External.MediaServers.Plex.Models.UserRequest.raml) | 200: [PlexAuthentication](../api/raml/types/Ombi.Api.External.MediaServers.Plex.Models.PlexAuthentication.raml) | Credential-bearing account/OAuth workflow stays outside model tools. |
| 099 | [POST `/api/v1/Plex/Libraries`](../api/raml/api.raml#L1477) | A | `ombi_integration_read / plex_libraries` | body [PlexServers](../api/raml/types/Ombi.Core.Settings.Models.External.PlexServers.raml) | 200: [PlexLibrariesResponse](../api/raml/types/Ombi.Models.External.PlexLibrariesResponse.raml) | Private saved-server POST alternative. |
| 100 | [GET `/api/v1/Plex/Libraries/{machineId}`](../api/raml/api.raml#L1497) | D | `ombi_integration_read / plex_libraries` | path `machineId`:string required | 200: [PlexLibrariesLiteResponse](../api/raml/types/Ombi.Models.External.PlexLibrariesLiteResponse.raml) | — |
| 101 | [POST `/api/v1/Plex/user`](../api/raml/api.raml#L1510) | X | `Plex account provisioning` | body [PlexUserViewModel](../api/raml/types/Ombi.Models.External.PlexUserViewModel.raml) | 200: body unspecified | Side effects are insufficiently specified; no guessed read lookup. |
| 102 | [GET `/api/v1/Plex/servers`](../api/raml/api.raml#L1525) | D | `ombi_integration_read / plex:servers` | none documented | 200: body unspecified | — |
| 103 | [POST `/api/v1/Plex/servers`](../api/raml/api.raml#L1525) | I | `Plex authentication/provisioning` | body [UserRequest](../api/raml/types/Ombi.Api.External.MediaServers.Plex.Models.UserRequest.raml) | 200: [PlexServersViewModel](../api/raml/types/Ombi.Models.External.PlexServersViewModel.raml) | Credential-bearing account/OAuth workflow stays outside model tools. |
| 104 | [GET `/api/v1/Plex/friends`](../api/raml/api.raml#L1552) | D | `ombi_integration_read / plex:friends` | none documented | 200: array&lt;[UsersViewModel](../api/raml/types/Ombi.Models.External.UsersViewModel.raml)&gt; | — |
| 105 | [POST `/api/v1/Plex/oauth`](../api/raml/api.raml#L1564) | I | `Plex authentication/provisioning` | body [PlexOAuthViewModel](../api/raml/types/Ombi.Models.PlexOAuthViewModel.raml) | 200: body unspecified | Credential-bearing account/OAuth workflow stays outside model tools. |
| 106 | [GET `/api/v1/Plex/WatchlistUsers`](../api/raml/api.raml#L1579) | D | `ombi_integration_read / plex:watchlist_users` | none documented | 200: array&lt;[PlexUserWatchlistModel](../api/raml/types/Ombi.Core.Models.PlexUserWatchlistModel.raml)&gt; | — |
| 107 | [POST `/api/v1/Plex/WatchlistUsers/revalidate`](../api/raml/api.raml#L1589) | D | `ombi_job_run / revalidate_watchlist` | none documented | 200: body unspecified | — |
| 108 | [POST `/api/v1/Radarr/Profiles`](../api/raml/api.raml#L1595) | A | `ombi_integration_read / options:radarr/profiles` | body [RadarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.RadarrSettings.raml) | 200: body unspecified | Private saved settings only; no connection overrides. |
| 109 | [GET `/api/v1/Radarr/Profiles`](../api/raml/api.raml#L1595) | D | `ombi_integration_read / options:radarr/profiles` | none documented | 200: body unspecified | — |
| 110 | [GET `/api/v1/Radarr/enabled`](../api/raml/api.raml#L1620) | D | `ombi_integration_read / options:radarr/enabled` | none documented | 200: boolean | — |
| 111 | [POST `/api/v1/Radarr/RootFolders`](../api/raml/api.raml#L1629) | A | `ombi_integration_read / options:radarr/root_folders` | body [RadarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.RadarrSettings.raml) | 200: array&lt;[RadarrRootFolder](../api/raml/types/Ombi.Api.External.ExternalApis.Radarr.Models.RadarrRootFolder.raml)&gt; | Private saved settings only; no connection overrides. |
| 112 | [GET `/api/v1/Radarr/RootFolders`](../api/raml/api.raml#L1629) | D | `ombi_integration_read / options:radarr/root_folders` | none documented | 200: array&lt;[RadarrRootFolder](../api/raml/types/Ombi.Api.External.ExternalApis.Radarr.Models.RadarrRootFolder.raml)&gt; | — |
| 113 | [GET `/api/v1/Radarr/Profiles/4k`](../api/raml/api.raml#L1662) | D | `ombi_integration_read / options:radarr/profiles/4k` | none documented | 200: body unspecified | — |
| 114 | [GET `/api/v1/Radarr/RootFolders/4k`](../api/raml/api.raml#L1671) | D | `ombi_integration_read / options:radarr/root_folders/4k` | none documented | 200: array&lt;[RadarrRootFolder](../api/raml/types/Ombi.Api.External.ExternalApis.Radarr.Models.RadarrRootFolder.raml)&gt; | — |
| 115 | [POST `/api/v1/Radarr/tags`](../api/raml/api.raml#L1684) | A | `ombi_integration_read / options:radarr/tags` | body [SonarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SonarrSettings.raml) | 200: array&lt;[Tag](../api/raml/types/Ombi.Api.External.ExternalApis.Radarr.Models.Tag.raml)&gt; | Private saved settings only; no connection overrides. RAML body unexpectedly references SonarrSettings; verify. |
| 116 | [GET `/api/v1/Radarr/tags`](../api/raml/api.raml#L1684) | D | `ombi_integration_read / options:radarr/tags` | none documented | 200: array&lt;[Tag](../api/raml/types/Ombi.Api.External.ExternalApis.Radarr.Models.Tag.raml)&gt; | — |
| 117 | [GET `/api/v1/RecentlyAdded/movies`](../api/raml/api.raml#L1716) | D | `ombi_library / recent` | none documented | 200: array&lt;[RecentlyAddedMovieModel](../api/raml/types/Ombi.Core.Models.RecentlyAddedMovieModel.raml)&gt; | grouped=true only for tv/grouped. |
| 118 | [GET `/api/v1/RecentlyAdded/tv`](../api/raml/api.raml#L1728) | D | `ombi_library / recent` | none documented | 200: array&lt;[RecentlyAddedMovieModel](../api/raml/types/Ombi.Core.Models.RecentlyAddedMovieModel.raml)&gt; | grouped=true only for tv/grouped. |
| 119 | [GET `/api/v1/RecentlyAdded/tv/grouped`](../api/raml/api.raml#L1740) | D | `ombi_library / recent` | none documented | 200: array&lt;[RecentlyAddedMovieModel](../api/raml/types/Ombi.Core.Models.RecentlyAddedMovieModel.raml)&gt; | grouped=true only for tv/grouped. |
| 120 | [GET `/api/v1/Request/movie/{count}/{position}/{orderType}/{statusType}/{availabilityType}`](../api/raml/api.raml#L1752) | A | `ombi_requests / list` | path `count`:unspecified required; path `position`:unspecified required; path `orderType`:unspecified required; path `statusType`:integer required; path `availabilityType`:integer required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.MovieRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | Legacy parent/array/filter semantics need verified adapter; not equivalent to v2 TV child pages. |
| 121 | [GET `/api/v1/Request/movie/info/{requestId}`](../api/raml/api.raml#L1782) | D | `ombi_requests / get:movie` | path `requestId`:unspecified required | 200: [MovieRequests](../api/raml/types/Ombi.Store.Entities.Requests.MovieRequests.raml) | — |
| 122 | [GET `/api/v1/Request/movie/total`](../api/raml/api.raml#L1798) | D | `ombi_request_stats / total` | none documented | 200: integer | — |
| 123 | [GET `/api/v1/Request/movie`](../api/raml/api.raml#L1809) | A | `ombi_requests / list` | none documented | 200: array&lt;[MovieRequests](../api/raml/types/Ombi.Store.Entities.Requests.MovieRequests.raml)&gt; | Legacy parent/array/filter semantics need verified adapter; not equivalent to v2 TV child pages. |
| 124 | [POST `/api/v1/Request/movie`](../api/raml/api.raml#L1809) | D | `ombi_request_create / movie` | body [MovieRequestViewModel](../api/raml/types/Ombi.Core.Models.Requests.MovieRequestViewModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 125 | [PUT `/api/v1/Request/movie`](../api/raml/api.raml#L1809) | X | `full entity replacement` | body [MovieRequests](../api/raml/types/Ombi.Store.Entities.Requests.MovieRequests.raml) | 200: [MovieRequests](../api/raml/types/Ombi.Store.Entities.Requests.MovieRequests.raml) | Use typed options/moderation instead; do not expose recursive server-owned state. |
| 126 | [GET `/api/v1/Request/movie/search/{searchTerm}`](../api/raml/api.raml#L1859) | D | `ombi_requests / search` | path `searchTerm`:unspecified required | 200: array&lt;[MovieRequests](../api/raml/types/Ombi.Store.Entities.Requests.MovieRequests.raml)&gt; | — |
| 127 | [DELETE `/api/v1/Request/movie/{requestId}`](../api/raml/api.raml#L1876) | D | `ombi_request_delete` | path `requestId`:unspecified required | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 128 | [DELETE `/api/v1/Request/movie/all`](../api/raml/api.raml#L1892) | X | `manual bulk deletion` | none documented | 200: body unspecified | Unbounded delete-all deliberately absent; selected single deletions remain possible. |
| 129 | [POST `/api/v1/Request/movie/approve`](../api/raml/api.raml#L1900) | D | `ombi_request_moderate / approve` | body [MovieUpdateModel](../api/raml/types/Ombi.Core.Models.Requests.MovieUpdateModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 130 | [POST `/api/v1/Request/movie/available`](../api/raml/api.raml#L1920) | D | `ombi_request_moderate / mark_available` | body [MovieUpdateModel](../api/raml/types/Ombi.Core.Models.Requests.MovieUpdateModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 131 | [POST `/api/v1/Request/movie/unavailable`](../api/raml/api.raml#L1940) | D | `ombi_request_moderate / mark_unavailable` | body [MovieUpdateModel](../api/raml/types/Ombi.Core.Models.Requests.MovieUpdateModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 132 | [PUT `/api/v1/Request/movie/deny`](../api/raml/api.raml#L1960) | D | `ombi_request_moderate / deny` | body [DenyMovieModel](../api/raml/types/Ombi.Core.Models.Requests.DenyMovieModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 133 | [GET `/api/v1/Request/tv/total`](../api/raml/api.raml#L1980) | D | `ombi_request_stats / total` | none documented | 200: integer | — |
| 134 | [GET `/api/v1/Request/tv/{count}/{position}/{orderType}/{statusFilterType}/{availabilityFilterType}`](../api/raml/api.raml#L1991) | A | `ombi_requests / list` | path `count`:unspecified required; path `position`:unspecified required; path `orderType`:integer required; path `statusFilterType`:string required; path `availabilityFilterType`:string required; query `statusType`:integer optional; query `availabilityType`:integer optional | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.TvRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | Legacy parent/array/filter semantics need verified adapter; not equivalent to v2 TV child pages. |
| 135 | [GET `/api/v1/Request/tvlite/{count}/{position}/{orderType}/{statusFilterType}/{availabilityFilterType}`](../api/raml/api.raml#L2027) | A | `ombi_requests / list` | path `count`:unspecified required; path `position`:unspecified required; path `orderType`:integer required; path `statusFilterType`:string required; path `availabilityFilterType`:string required; query `statusType`:integer optional; query `availabilityType`:integer optional | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.TvRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | Legacy parent/array/filter semantics need verified adapter; not equivalent to v2 TV child pages. |
| 136 | [GET `/api/v1/Request/tv`](../api/raml/api.raml#L2063) | A | `ombi_requests / list` | none documented | 200: array&lt;[TvRequests](../api/raml/types/Ombi.Store.Entities.Requests.TvRequests.raml)&gt; | Legacy parent/array/filter semantics need verified adapter; not equivalent to v2 TV child pages. |
| 137 | [POST `/api/v1/Request/tv`](../api/raml/api.raml#L2063) | D | `ombi_request_create / tv:tvdb` | body [TvRequestViewModel](../api/raml/types/Ombi.Core.Models.Requests.TvRequestViewModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 138 | [PUT `/api/v1/Request/tv`](../api/raml/api.raml#L2063) | X | `full entity replacement` | body [TvRequests](../api/raml/types/Ombi.Store.Entities.Requests.TvRequests.raml) | 200: [TvRequests](../api/raml/types/Ombi.Store.Entities.Requests.TvRequests.raml) | Use typed options/moderation instead; do not expose recursive server-owned state. |
| 139 | [GET `/api/v1/Request/tvlite`](../api/raml/api.raml#L2113) | A | `ombi_requests / list` | none documented | 200: array&lt;[TvRequests](../api/raml/types/Ombi.Store.Entities.Requests.TvRequests.raml)&gt; | Legacy parent/array/filter semantics need verified adapter; not equivalent to v2 TV child pages. |
| 140 | [GET `/api/v1/Request/tv/{requestId}`](../api/raml/api.raml#L2125) | D | `ombi_requests / get:tv_parent` | path `requestId`:integer required | 200: [TvRequests](../api/raml/types/Ombi.Store.Entities.Requests.TvRequests.raml) | — |
| 141 | [DELETE `/api/v1/Request/tv/{requestId}`](../api/raml/api.raml#L2125) | D | `ombi_request_delete` | path `requestId`:unspecified required | 200: body unspecified | — |
| 142 | [GET `/api/v1/Request/tv/search/{searchTerm}`](../api/raml/api.raml#L2152) | D | `ombi_requests / search` | path `searchTerm`:unspecified required | 200: array&lt;[TvRequests](../api/raml/types/Ombi.Store.Entities.Requests.TvRequests.raml)&gt; | — |
| 143 | [PUT `/api/v1/Request/tv/root/{requestId}/{rootFolderId}`](../api/raml/api.raml#L2169) | D | `ombi_request_options / tv_root` | path `requestId`:integer required; path `rootFolderId`:integer required | 200: boolean | — |
| 144 | [PUT `/api/v1/Request/tv/quality/{requestId}/{qualityId}`](../api/raml/api.raml#L2187) | D | `ombi_request_options / tv_quality` | path `requestId`:integer required; path `qualityId`:integer required | 200: boolean | — |
| 145 | [PUT `/api/v1/Request/tv/child`](../api/raml/api.raml#L2205) | X | `full entity replacement` | body [ChildRequests](../api/raml/types/Ombi.Store.Entities.Requests.ChildRequests.raml) | 200: [ChildRequests](../api/raml/types/Ombi.Store.Entities.Requests.ChildRequests.raml) | Use typed options/moderation instead; do not expose recursive server-owned state. |
| 146 | [PUT `/api/v1/Request/tv/deny`](../api/raml/api.raml#L2225) | D | `ombi_request_moderate / deny` | body [DenyTvModel](../api/raml/types/Ombi.Models.DenyTvModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | TV child-ID semantics must be verified. |
| 147 | [POST `/api/v1/Request/tv/available`](../api/raml/api.raml#L2245) | D | `ombi_request_moderate / mark_available` | body [TvUpdateModel](../api/raml/types/Ombi.Models.TvUpdateModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | TV child-ID semantics must be verified. |
| 148 | [POST `/api/v1/Request/tv/unavailable`](../api/raml/api.raml#L2265) | D | `ombi_request_moderate / mark_unavailable` | body [TvUpdateModel](../api/raml/types/Ombi.Models.TvUpdateModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | TV child-ID semantics must be verified. |
| 149 | [POST `/api/v1/Request/tv/approve`](../api/raml/api.raml#L2285) | D | `ombi_request_moderate / approve` | body [TvUpdateModel](../api/raml/types/Ombi.Models.TvUpdateModel.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | TV child-ID semantics must be verified. |
| 150 | [DELETE `/api/v1/Request/tv/child/{requestId}`](../api/raml/api.raml#L2305) | D | `ombi_request_delete` | path `requestId`:unspecified required | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 151 | [GET `/api/v1/Request/tv/{requestId}/child`](../api/raml/api.raml#L2321) | D | `ombi_requests / children` | path `requestId`:unspecified required | 200: array&lt;[ChildRequests](../api/raml/types/Ombi.Store.Entities.Requests.ChildRequests.raml)&gt; | — |
| 152 | [GET `/api/v1/Request/count`](../api/raml/api.raml#L2338) | D | `ombi_request_stats / counts` | none documented | 200: [RequestCountModel](../api/raml/types/Ombi.Core.Models.Requests.RequestCountModel.raml) | — |
| 153 | [GET `/api/v1/Request/userhasrequest`](../api/raml/api.raml#L2349) | D | `ombi_request_stats / has_requests` | query `userId`:string optional | 200: boolean | — |
| 154 | [POST `/api/v1/Request/movie/subscribe/{requestId}`](../api/raml/api.raml#L2364) | D | `ombi_request_subscribe / subscribe` | path `requestId`:integer required | 200: boolean | — |
| 155 | [POST `/api/v1/Request/tv/subscribe/{requestId}`](../api/raml/api.raml#L2379) | D | `ombi_request_subscribe / subscribe` | path `requestId`:integer required | 200: boolean | — |
| 156 | [POST `/api/v1/Request/movie/unsubscribe/{requestId}`](../api/raml/api.raml#L2394) | D | `ombi_request_subscribe / unsubscribe` | path `requestId`:integer required | 200: boolean | — |
| 157 | [POST `/api/v1/Request/tv/unsubscribe/{requestId}`](../api/raml/api.raml#L2409) | D | `ombi_request_subscribe / unsubscribe` | path `requestId`:integer required | 200: boolean | — |
| 158 | [GET `/api/v1/Request/movie/remaining`](../api/raml/api.raml#L2424) | D | `ombi_request_stats / quota` | none documented | 200: [RequestQuotaCountModel](../api/raml/types/Ombi.Core.Models.RequestQuotaCountModel.raml) | — |
| 159 | [GET `/api/v1/Request/tv/remaining`](../api/raml/api.raml#L2435) | D | `ombi_request_stats / quota` | none documented | 200: [RequestQuotaCountModel](../api/raml/types/Ombi.Core.Models.RequestQuotaCountModel.raml) | — |
| 160 | [GET `/api/v1/RequestRetry`](../api/raml/api.raml#L2446) | D | `ombi_requests / retry_queue` | none documented | 200: array&lt;[FailedRequestViewModel](../api/raml/types/Ombi.Models.FailedRequestViewModel.raml)&gt; | Privileged queue read. |
| 161 | [DELETE `/api/v1/RequestRetry/{queueId}`](../api/raml/api.raml#L2458) | D | `ombi_retry_remove` | path `queueId`:integer required | 200: body unspecified | — |
| 162 | [GET `/api/v2/Requests/movie/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2468) | D | `ombi_requests / list` | path `count`:unspecified required; path `position`:unspecified required; path `sort`:unspecified required; path `sortOrder`:unspecified required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.MovieRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 163 | [GET `/api/v2/Requests/movie/availble/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2500) | A | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.MovieRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | Misspelled alias; prefer available. |
| 164 | [GET `/api/v2/Requests/movie/available/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2526) | D | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.MovieRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 165 | [GET `/api/v2/Requests/movie/processing/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2552) | D | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.MovieRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 166 | [GET `/api/v2/Requests/movie/pending/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2578) | D | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.MovieRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 167 | [GET `/api/v2/Requests/movie/denied/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2604) | D | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.MovieRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 168 | [GET `/api/v2/Requests/movie/unavailable/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2630) | D | `ombi_requests / list` | path `count`:unspecified required; path `position`:unspecified required; path `sort`:unspecified required; path `sortOrder`:unspecified required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.MovieRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 169 | [GET `/api/v2/Requests/tv/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2662) | D | `ombi_requests / list` | path `count`:unspecified required; path `position`:unspecified required; path `sort`:unspecified required; path `sortOrder`:unspecified required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.ChildRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 170 | [GET `/api/v2/Requests/tv/pending/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2694) | D | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.ChildRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 171 | [GET `/api/v2/Requests/tv/processing/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2720) | D | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.ChildRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 172 | [GET `/api/v2/Requests/tv/available/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2746) | D | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.ChildRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 173 | [GET `/api/v2/Requests/tv/denied/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2772) | D | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.ChildRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 174 | [GET `/api/v2/Requests/tv/unavailable/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2798) | D | `ombi_requests / list` | path `count`:unspecified required; path `position`:unspecified required; path `sort`:unspecified required; path `sortOrder`:unspecified required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.ChildRequests__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 175 | [POST `/api/v2/Requests/movie/advancedoptions`](../api/raml/api.raml#L2830) | D | `ombi_request_options / advanced` | body [MediaAdvancedOptions](../api/raml/types/Ombi.Core.Models.Requests.MediaAdvancedOptions.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 176 | [POST `/api/v2/Requests/tv/advancedoptions`](../api/raml/api.raml#L2852) | D | `ombi_request_options / advanced` | body [MediaAdvancedOptions](../api/raml/types/Ombi.Core.Models.Requests.MediaAdvancedOptions.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 177 | [GET `/api/v2/Requests/album/available/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2874) | D | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.AlbumRequest__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 178 | [GET `/api/v2/Requests/album/processing/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2900) | D | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.AlbumRequest__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 179 | [GET `/api/v2/Requests/album/pending/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2926) | D | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.AlbumRequest__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 180 | [GET `/api/v2/Requests/album/denied/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2952) | D | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.AlbumRequest__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 181 | [GET `/api/v2/Requests/album/{count}/{position}/{sort}/{sortOrder}`](../api/raml/api.raml#L2978) | D | `ombi_requests / list` | path `count`:integer required; path `position`:integer required; path `sort`:string required; path `sortOrder`:string required | 200: [RequestsViewModel](../api/raml/types/Ombi.Core.Models.UI.RequestsViewModel_1__Ombi.Store.Entities.Requests.AlbumRequest__Ombi.Store__Version_3.0.0.0__Culture_neutral__PublicKeyToken_null__.raml) | — |
| 182 | [POST `/api/v2/Requests/tv`](../api/raml/api.raml#L3004) | D | `ombi_request_create / tv:tmdb` | body [TvRequestViewModelV2](../api/raml/types/Ombi.Core.Models.Requests.TvRequestViewModelV2.raml) | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | — |
| 183 | [POST `/api/v2/Requests/reprocess/{type}/{requestId}/{is4K}`](../api/raml/api.raml#L3028) | D | `ombi_request_reprocess` | path `type`:[RequestType](../api/raml/types/Ombi.Store.Entities.RequestType.raml) required; path `requestId`:integer required; path `is4K`:boolean required | 200: body unspecified | Verify enum/target/4K combinations; do not infer album exclusion. |
| 184 | [POST `/api/v2/Requests/movie/collection/{collectionId}`](../api/raml/api.raml#L3044) | D | `ombi_request_create / collection` | path `collectionId`:integer required | 200: [RequestEngineResult](../api/raml/types/Ombi.Core.Engine.RequestEngineResult.raml) | No request body documented; success is RequestEngineResult. |
| 185 | [GET `/api/v2/Requests/recentlyRequested`](../api/raml/api.raml#L3061) | D | `ombi_requests / recent` | none documented | 200: array&lt;[RecentlyRequestedModel](../api/raml/types/Ombi.Core.Models.Requests.RecentlyRequestedModel.raml)&gt; | — |
| 186 | [POST `/api/v2/Search/multi/{searchTerm}`](../api/raml/api.raml#L3077) | D | `ombi_search / multi` | path `searchTerm`:unspecified required; body [MultiSearchFilter](../api/raml/types/Ombi.Core.Models.Search.V2.MultiSearchFilter.raml) | 200: array&lt;[MultiSearchResult](../api/raml/types/Ombi.Core.Models.Search.V2.MultiSearchResult.raml)&gt; | — |
| 187 | [GET `/api/v2/Search/Genres/{media}`](../api/raml/api.raml#L3111) | D | `ombi_reference / genres` | path `media`:unspecified required | 200: array&lt;[Genre](../api/raml/types/Ombi.TheMovieDbApi.Models.Genre.raml)&gt; | — |
| 188 | [GET `/api/v2/Search/Languages`](../api/raml/api.raml#L3134) | D | `ombi_reference / languages` | none documented | 200: array&lt;[Language](../api/raml/types/Ombi.TheMovieDbApi.Models.Language.raml)&gt; | — |
| 189 | [GET `/api/v2/Search/movie/{movieDbId}`](../api/raml/api.raml#L3150) | D | `ombi_media / details` | path `movieDbId`:unspecified required | 200: [MovieFullInfoViewModel](../api/raml/types/Ombi.Core.Models.Search.V2.MovieFullInfoViewModel.raml) | — |
| 190 | [GET `/api/v2/Search/movie/imdb/{imdbid}`](../api/raml/api.raml#L3170) | D | `ombi_media / details` | path `imdbId`:string required | 200: [MovieFullInfoViewModel](../api/raml/types/Ombi.Core.Models.Search.V2.MovieFullInfoViewModel.raml) | — |
| 191 | [GET `/api/v2/Search/movie/request/{requestId}`](../api/raml/api.raml#L3187) | D | `ombi_media / by_request` | path `requestId`:integer required | 200: [MovieFullInfoViewModel](../api/raml/types/Ombi.Core.Models.Search.V2.MovieFullInfoViewModel.raml) | Request-ID namespace requires verification. |
| 192 | [GET `/api/v2/Search/movie/collection/{collectionId}`](../api/raml/api.raml#L3206) | D | `ombi_discover / collection` | path `collectionId`:unspecified required | 200: [MovieCollectionsViewModel](../api/raml/types/Ombi.Core.Models.Search.V2.MovieCollectionsViewModel.raml) | — |
| 193 | [GET `/api/v2/Search/tv/{tvdbId}`](../api/raml/api.raml#L3226) | D | `ombi_media / details` | path `tvdbid`:unspecified required | 200: [SearchFullInfoTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.V2.SearchFullInfoTvShowViewModel.raml) | — |
| 194 | [GET `/api/v2/Search/tv/request/{requestId}`](../api/raml/api.raml#L3246) | D | `ombi_media / by_request` | path `requestId`:integer required | 200: [SearchFullInfoTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.V2.SearchFullInfoTvShowViewModel.raml) | Request-ID namespace requires verification. |
| 195 | [GET `/api/v2/Search/tv/moviedb/{moviedbid}`](../api/raml/api.raml#L3265) | D | `ombi_media / details` | path `moviedbid`:string required | 200: [SearchFullInfoTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.V2.SearchFullInfoTvShowViewModel.raml) | — |
| 196 | [POST `/api/v2/Search/movie/similar`](../api/raml/api.raml#L3284) | D | `ombi_discover / similar` | body [SimilarMoviesRefineModel](../api/raml/types/Ombi.Models.SimilarMoviesRefineModel.raml) | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | — |
| 197 | [GET `/api/v2/Search/movie/popular`](../api/raml/api.raml#L3321) | A | `ombi_discover / browse` | none documented | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | Nonpaged/legacy alternate only. |
| 198 | [GET `/api/v2/Search/movie/popular/{currentPosition}/{amountToLoad}`](../api/raml/api.raml#L3349) | D | `ombi_discover / browse` | path `currentPosition`:integer required; path `amountToLoad`:integer required | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | — |
| 199 | [POST `/api/v2/Search/advancedSearch/movie/{currentPosition}/{amountToLoad}`](../api/raml/api.raml#L3384) | D | `ombi_discover / advanced_movie` | path `currentPosition`:integer required; path `amountToLoad`:integer required; body [DiscoverModel](../api/raml/types/Ombi.Api.External.ExternalApis.TheMovieDb.Models.DiscoverModel.raml) | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | — |
| 200 | [GET `/api/v2/Search/movie/seasonal/{currentPosition}/{amountToLoad}`](../api/raml/api.raml#L3428) | D | `ombi_discover / browse` | path `currentPosition`:integer required; path `amountToLoad`:integer required | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | — |
| 201 | [GET `/api/v2/Search/movie/requested/{currentPosition}/{amountToLoad}`](../api/raml/api.raml#L3463) | D | `ombi_discover / browse` | path `currentPosition`:integer required; path `amountToLoad`:integer required | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | — |
| 202 | [GET `/api/v2/Search/tv/requested/{currentPosition}/{amountToLoad}`](../api/raml/api.raml#L3498) | D | `ombi_discover / browse` | path `currentPosition`:integer required; path `amountToLoad`:integer required | 200: array&lt;[SearchFullInfoTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.V2.SearchFullInfoTvShowViewModel.raml)&gt; | — |
| 203 | [GET `/api/v2/Search/movie/nowplaying`](../api/raml/api.raml#L3533) | A | `ombi_discover / browse` | none documented | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | Nonpaged/legacy alternate only. |
| 204 | [GET `/api/v2/Search/movie/nowplaying/{currentPosition}/{amountToLoad}`](../api/raml/api.raml#L3561) | D | `ombi_discover / browse` | path `currentPosition`:integer required; path `amountToLoad`:integer required | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | — |
| 205 | [GET `/api/v2/Search/movie/toprated`](../api/raml/api.raml#L3596) | A | `ombi_discover / browse` | none documented | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | Nonpaged/legacy alternate only. |
| 206 | [GET `/api/v2/Search/movie/toprated/{currentPosition}/{amountToLoad}`](../api/raml/api.raml#L3624) | D | `ombi_discover / browse` | path `currentPosition`:integer required; path `amountToLoad`:integer required | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | — |
| 207 | [GET `/api/v2/Search/movie/upcoming`](../api/raml/api.raml#L3659) | A | `ombi_discover / browse` | none documented | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | Nonpaged/legacy alternate only. |
| 208 | [GET `/api/v2/Search/movie/upcoming/{currentPosition}/{amountToLoad}`](../api/raml/api.raml#L3687) | D | `ombi_discover / browse` | path `currentPosition`:integer required; path `amountToLoad`:integer required | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | — |
| 209 | [GET `/api/v2/Search/tv/popular/{currentPosition}/{amountToLoad}`](../api/raml/api.raml#L3722) | D | `ombi_discover / browse` | path `currentPosition`:integer required; path `amountToLoad`:integer required | 200: array&lt;[SearchTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchTvShowViewModel.raml)&gt; | — |
| 210 | [GET `/api/v2/Search/tv/anticipated/{currentPosition}/{amountToLoad}`](../api/raml/api.raml#L3757) | D | `ombi_discover / browse` | path `currentPosition`:integer required; path `amountToLoad`:integer required | 200: array&lt;[SearchTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchTvShowViewModel.raml)&gt; | — |
| 211 | [GET `/api/v2/Search/tv/mostwatched/{currentPosition}/{amountToLoad}`](../api/raml/api.raml#L3792) | D | `ombi_discover / browse` | path `currentPosition`:integer required; path `amountToLoad`:integer required | 200: array&lt;[SearchTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchTvShowViewModel.raml)&gt; | — |
| 212 | [GET `/api/v2/Search/tv/trending/{currentPosition}/{amountToLoad}`](../api/raml/api.raml#L3827) | D | `ombi_discover / browse` | path `currentPosition`:integer required; path `amountToLoad`:integer required | 200: array&lt;[SearchTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchTvShowViewModel.raml)&gt; | — |
| 213 | [GET `/api/v2/Search/actor/{actorId}/movie`](../api/raml/api.raml#L3862) | D | `ombi_discover / credits` | path `actorId`:unspecified required | 200: [ActorCredits](../api/raml/types/Ombi.Api.External.ExternalApis.TheMovieDb.Models.ActorCredits.raml) | — |
| 214 | [GET `/api/v2/Search/actor/{actorId}/tv`](../api/raml/api.raml#L3892) | D | `ombi_discover / credits` | path `actorId`:unspecified required | 200: [ActorCredits](../api/raml/types/Ombi.Api.External.ExternalApis.TheMovieDb.Models.ActorCredits.raml) | — |
| 215 | [GET `/api/v2/Search/artist/{artistId}`](../api/raml/api.raml#L3922) | D | `ombi_media / details` | path `artistId`:string required | 200: [ArtistInformation](../api/raml/types/Ombi.Core.Models.Search.V2.Music.ArtistInformation.raml) | — |
| 216 | [GET `/api/v2/Search/artist/request/{requestId}`](../api/raml/api.raml#L3949) | D | `ombi_media / by_request` | path `requestId`:integer required | 200: [ArtistInformation](../api/raml/types/Ombi.Core.Models.Search.V2.Music.ArtistInformation.raml) | Request-ID namespace requires verification. |
| 217 | [GET `/api/v2/Search/artist/album/{albumId}`](../api/raml/api.raml#L3976) | D | `ombi_media / details` | path `albumId`:string required | 200: [ReleaseGroup](../api/raml/types/Ombi.Core.Models.Search.V2.Music.ReleaseGroup.raml) | — |
| 218 | [GET `/api/v2/Search/releasegroupart/{musicBrainzId}`](../api/raml/api.raml#L4003) | D | `ombi_library / album_art` | path `musicBrainzId`:string required | 200: [AlbumArt](../api/raml/types/Ombi.Core.Models.Search.V2.Music.AlbumArt.raml) | — |
| 219 | [GET `/api/v2/Search/ratings/movie/{name}/{year}`](../api/raml/api.raml#L4030) | D | `ombi_media / ratings` | path `name`:string required; path `year`:integer required | 200: [MovieRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.MovieRatings.raml) | — |
| 220 | [GET `/api/v2/Search/ratings/tv/{name}/{year}`](../api/raml/api.raml#L4060) | D | `ombi_media / ratings` | path `name`:string required; path `year`:integer required | 200: [TvRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.TvRatings.raml) | — |
| 221 | [GET `/api/v2/Search/stream/movie/{movieDbId}`](../api/raml/api.raml#L4090) | D | `ombi_media / streaming` | path `movieDBId`:integer required | 200: array&lt;[StreamingData](../api/raml/types/Ombi.Core.Models.Search.V2.StreamingData.raml)&gt; | — |
| 222 | [GET `/api/v2/Search/stream/tv/{movieDbId}`](../api/raml/api.raml#L4120) | D | `ombi_media / streaming` | path `movieDbId`:integer required | 200: array&lt;[StreamingData](../api/raml/types/Ombi.Core.Models.Search.V2.StreamingData.raml)&gt; | — |
| 223 | [GET `/api/v1/Search/movie/{searchTerm}`](../api/raml/api.raml#L4150) | D | `ombi_search / text` | path `searchTerm`:unspecified required | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | — |
| 224 | [POST `/api/v1/Search/movie/actor`](../api/raml/api.raml#L4173) | D | `ombi_search / actor` | body [SearchActorModel](../api/raml/types/Ombi.Models.SearchActorModel.raml) | 200: body unspecified | — |
| 225 | [POST `/api/v1/Search/movie`](../api/raml/api.raml#L4202) | D | `ombi_search / movie_refine` | body [SearchMovieRefineModel](../api/raml/types/Ombi.Models.SearchMovieRefineModel.raml) | 200: body unspecified | — |
| 226 | [GET `/api/v1/Search/movie/info/{theMovieDbId}`](../api/raml/api.raml#L4231) | A | `ombi_media / details` | path `theMovieDbId`:unspecified required | 200: [SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml) | Legacy details alternative. |
| 227 | [POST `/api/v1/Search/movie/info`](../api/raml/api.raml#L4253) | D | `ombi_media / movie_localized` | body [SearchMovieExtraInfoRefineModel](../api/raml/types/Ombi.Models.SearchMovieExtraInfoRefineModel.raml) | 200: body unspecified | — |
| 228 | [POST `/api/v1/Search/movie/similar`](../api/raml/api.raml#L4282) | A | `ombi_discover / similar` | body [SimilarMoviesRefineModel](../api/raml/types/Ombi.Models.SimilarMoviesRefineModel.raml) | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | Preserve language semantics; no automatic fallback. |
| 229 | [GET `/api/v1/Search/movie/{theMovieDbId}/similar`](../api/raml/api.raml#L4309) | A | `ombi_discover / similar` | path `theMovieDbId`:unspecified required | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | Preserve language semantics; no automatic fallback. |
| 230 | [GET `/api/v1/Search/movie/popular`](../api/raml/api.raml#L4332) | A | `ombi_discover / browse` | none documented | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | Nonpaged/legacy alternate only. |
| 231 | [GET `/api/v1/Search/movie/nowplaying`](../api/raml/api.raml#L4350) | A | `ombi_discover / browse` | none documented | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | Nonpaged/legacy alternate only. |
| 232 | [GET `/api/v1/Search/movie/toprated`](../api/raml/api.raml#L4368) | A | `ombi_discover / browse` | none documented | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | Nonpaged/legacy alternate only. |
| 233 | [GET `/api/v1/Search/movie/upcoming`](../api/raml/api.raml#L4386) | A | `ombi_discover / browse` | none documented | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | Nonpaged/legacy alternate only. |
| 234 | [GET `/api/v1/Search/tv/{searchTerm}`](../api/raml/api.raml#L4404) | D | `ombi_search / text` | path `searchTerm`:unspecified required | 200: array&lt;[SearchTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchTvShowViewModel.raml)&gt; | — |
| 235 | [GET `/api/v1/Search/tv/info/{tvdbId}`](../api/raml/api.raml#L4427) | A | `ombi_media / details` | path `tvdbId`:unspecified required | 200: [SearchTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchTvShowViewModel.raml) | Legacy details alternative. |
| 236 | [GET `/api/v1/Search/tv/popular`](../api/raml/api.raml#L4449) | A | `ombi_discover / browse` | none documented | 200: array&lt;[SearchTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchTvShowViewModel.raml)&gt; | Nonpaged/legacy alternate only. |
| 237 | [GET `/api/v1/Search/tv/anticipated`](../api/raml/api.raml#L4467) | A | `ombi_discover / browse` | none documented | 200: array&lt;[SearchTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchTvShowViewModel.raml)&gt; | Nonpaged/legacy alternate only. |
| 238 | [GET `/api/v1/Search/tv/mostwatched`](../api/raml/api.raml#L4485) | A | `ombi_discover / browse` | none documented | 200: array&lt;[SearchTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchTvShowViewModel.raml)&gt; | Nonpaged/legacy alternate only. |
| 239 | [GET `/api/v1/Search/tv/trending`](../api/raml/api.raml#L4503) | A | `ombi_discover / browse` | none documented | 200: array&lt;[SearchTvShowViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchTvShowViewModel.raml)&gt; | Nonpaged/legacy alternate only. |
| 240 | [GET `/api/v1/Search/music/artist/{searchTerm}`](../api/raml/api.raml#L4521) | D | `ombi_search / text` | path `searchTerm`:string required | 200: array&lt;[SearchArtistViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchArtistViewModel.raml)&gt; | — |
| 241 | [GET `/api/v1/Search/music/album/{searchTerm}`](../api/raml/api.raml#L4543) | D | `ombi_search / text` | path `searchTerm`:string required | 200: array&lt;[SearchAlbumViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchAlbumViewModel.raml)&gt; | — |
| 242 | [GET `/api/v1/Search/music/album/info/{foreignAlbumId}`](../api/raml/api.raml#L4565) | A | `ombi_media / details` | path `foreignAlbumId`:string required | 200: [SearchAlbumViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchAlbumViewModel.raml) | Legacy details alternative. |
| 243 | [GET `/api/v1/Search/music/artist/album/{foreignArtistId}`](../api/raml/api.raml#L4586) | D | `ombi_discover / artist_albums` | path `foreignArtistId`:string required | 200: array&lt;[SearchAlbumViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchAlbumViewModel.raml)&gt; | — |
| 244 | [GET `/api/v1/Settings/ombi`](../api/raml/api.raml#L4608) | D | `ombi_settings_read / ombi` | none documented | 200: [OmbiSettings](../api/raml/types/Ombi.Settings.Settings.Models.OmbiSettings.raml) | Allowlisted projection; no raw secrets. |
| 245 | [POST `/api/v1/Settings/ombi`](../api/raml/api.raml#L4608) | P | `ombi_settings_write / patch:ombi` | body [OmbiSettings](../api/raml/types/Ombi.Settings.Settings.Models.OmbiSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 246 | [GET `/api/v1/Settings/baseurl`](../api/raml/api.raml#L4638) | D | `ombi_settings_read / base_url` | none documented | 200: string | Allowlisted projection; no raw secrets. |
| 247 | [GET `/api/v1/Settings/about`](../api/raml/api.raml#L4649) | D | `ombi_server / about` | none documented | 200: [AboutViewModel](../api/raml/types/Ombi.Models.AboutViewModel.raml) | — |
| 248 | [POST `/api/v1/Settings/ombi/resetApi`](../api/raml/api.raml#L4658) | X | `manual administration` | none documented | 200: string | Key rotation would invalidate configured credentials; no tool. |
| 249 | [GET `/api/v1/Settings/plex`](../api/raml/api.raml#L4667) | D | `ombi_settings_read / plex` | none documented | 200: [PlexSettings](../api/raml/types/Ombi.Core.Settings.Models.External.PlexSettings.raml) | Allowlisted projection; no raw secrets. |
| 250 | [POST `/api/v1/Settings/plex`](../api/raml/api.raml#L4667) | P | `ombi_settings_write / patch:plex` | body [PlexSettings](../api/raml/types/Ombi.Core.Settings.Models.External.PlexSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 251 | [GET `/api/v1/Settings/clientid`](../api/raml/api.raml#L4697) | D | `ombi_settings_read / client_id` | none documented | 200: string | Allowlisted projection; no raw secrets. |
| 252 | [GET `/api/v1/Settings/emby`](../api/raml/api.raml#L4706) | D | `ombi_settings_read / emby` | none documented | 200: [EmbySettings](../api/raml/types/Ombi.Core.Settings.Models.External.EmbySettings.raml) | Allowlisted projection; no raw secrets. |
| 253 | [POST `/api/v1/Settings/emby`](../api/raml/api.raml#L4706) | P | `ombi_settings_write / patch:emby` | body [EmbySettings](../api/raml/types/Ombi.Core.Settings.Models.External.EmbySettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 254 | [GET `/api/v1/Settings/jellyfin`](../api/raml/api.raml#L4736) | D | `ombi_settings_read / jellyfin` | none documented | 200: [JellyfinSettings](../api/raml/types/Ombi.Core.Settings.Models.External.JellyfinSettings.raml) | Allowlisted projection; no raw secrets. |
| 255 | [POST `/api/v1/Settings/jellyfin`](../api/raml/api.raml#L4736) | P | `ombi_settings_write / patch:jellyfin` | body [JellyfinSettings](../api/raml/types/Ombi.Core.Settings.Models.External.JellyfinSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 256 | [GET `/api/v1/Settings/landingpage`](../api/raml/api.raml#L4766) | D | `ombi_settings_read / landingpage` | none documented | 200: [LandingPageSettings](../api/raml/types/Ombi.Core.Settings.Models.LandingPageSettings.raml) | Allowlisted projection; no raw secrets. |
| 257 | [POST `/api/v1/Settings/landingpage`](../api/raml/api.raml#L4766) | P | `ombi_settings_write / patch:landingpage` | body [LandingPageSettings](../api/raml/types/Ombi.Core.Settings.Models.LandingPageSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 258 | [GET `/api/v1/Settings/customization`](../api/raml/api.raml#L4796) | D | `ombi_settings_read / customization` | none documented | 200: [CustomizationSettings](../api/raml/types/Ombi.Settings.Settings.Models.CustomizationSettings.raml) | Allowlisted projection; no raw secrets. |
| 259 | [POST `/api/v1/Settings/customization`](../api/raml/api.raml#L4796) | P | `ombi_settings_write / patch:customization` | body [CustomizationSettings](../api/raml/types/Ombi.Settings.Settings.Models.CustomizationSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 260 | [GET `/api/v1/Settings/defaultlanguage`](../api/raml/api.raml#L4826) | D | `ombi_settings_read / default_language` | none documented | 200: string | Allowlisted projection; no raw secrets. |
| 261 | [GET `/api/v1/Settings/themes`](../api/raml/api.raml#L4837) | D | `ombi_settings_read / themes` | none documented | 200: array&lt;[PresetThemeViewModel](../api/raml/types/Ombi.Models.PresetThemeViewModel.raml)&gt; | Allowlisted projection; no raw secrets. |
| 262 | [GET `/api/v1/Settings/sonarr`](../api/raml/api.raml#L4849) | D | `ombi_settings_read / sonarr` | none documented | 200: [SonarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SonarrSettings.raml) | Allowlisted projection; no raw secrets. |
| 263 | [POST `/api/v1/Settings/sonarr`](../api/raml/api.raml#L4849) | P | `ombi_settings_write / patch:sonarr` | body [SonarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SonarrSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 264 | [GET `/api/v1/Settings/radarr`](../api/raml/api.raml#L4879) | D | `ombi_settings_read / radarr` | none documented | 200: [RadarrCombinedModel](../api/raml/types/Ombi.Settings.Settings.Models.External.RadarrCombinedModel.raml) | Allowlisted projection; no raw secrets. |
| 265 | [POST `/api/v1/Settings/radarr`](../api/raml/api.raml#L4879) | P | `ombi_settings_write / patch:radarr` | body [RadarrCombinedModel](../api/raml/types/Ombi.Settings.Settings.Models.External.RadarrCombinedModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 266 | [GET `/api/v1/Settings/lidarr`](../api/raml/api.raml#L4909) | D | `ombi_settings_read / lidarr` | none documented | 200: [LidarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.LidarrSettings.raml) | Allowlisted projection; no raw secrets. |
| 267 | [POST `/api/v1/Settings/lidarr`](../api/raml/api.raml#L4909) | P | `ombi_settings_write / patch:lidarr` | body [LidarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.LidarrSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 268 | [GET `/api/v1/Settings/lidarrenabled`](../api/raml/api.raml#L4939) | D | `ombi_settings_read / lidarrenabled` | none documented | 200: boolean | Allowlisted projection; no raw secrets. |
| 269 | [POST `/api/v1/Settings/authentication`](../api/raml/api.raml#L4950) | P | `ombi_settings_write / patch:authentication` | body [AuthenticationSettings](../api/raml/types/Ombi.Settings.Settings.Models.AuthenticationSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 270 | [GET `/api/v1/Settings/authentication`](../api/raml/api.raml#L4950) | D | `ombi_settings_read / authentication` | none documented | 200: [AuthenticationSettings](../api/raml/types/Ombi.Settings.Settings.Models.AuthenticationSettings.raml) | Allowlisted projection; no raw secrets. |
| 271 | [POST `/api/v1/Settings/Update`](../api/raml/api.raml#L4980) | P | `ombi_settings_write / patch:update` | body [UpdateSettings](../api/raml/types/Ombi.Settings.Settings.Models.UpdateSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 272 | [GET `/api/v1/Settings/Update`](../api/raml/api.raml#L4980) | D | `ombi_settings_read / update` | none documented | 200: [UpdateSettings](../api/raml/types/Ombi.Settings.Settings.Models.UpdateSettings.raml) | Allowlisted projection; no raw secrets. |
| 273 | [GET `/api/v1/Settings/UserManagement`](../api/raml/api.raml#L5010) | D | `ombi_settings_read / user_management` | none documented | 200: [UserManagementSettings](../api/raml/types/Ombi.Settings.Settings.Models.UserManagementSettings.raml) | Allowlisted projection; no raw secrets. |
| 274 | [POST `/api/v1/Settings/UserManagement`](../api/raml/api.raml#L5010) | P | `ombi_settings_write / patch:user_management` | body [UserManagementSettings](../api/raml/types/Ombi.Settings.Settings.Models.UserManagementSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 275 | [GET `/api/v1/Settings/CouchPotato`](../api/raml/api.raml#L5040) | D | `ombi_settings_read / couchpotato` | none documented | 200: [CouchPotatoSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.CouchPotatoSettings.raml) | Allowlisted projection; no raw secrets. |
| 276 | [POST `/api/v1/Settings/CouchPotato`](../api/raml/api.raml#L5040) | P | `ombi_settings_write / patch:couchpotato` | body [CouchPotatoSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.CouchPotatoSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 277 | [GET `/api/v1/Settings/DogNzb`](../api/raml/api.raml#L5070) | D | `ombi_settings_read / dognzb` | none documented | 200: [DogNzbSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.DogNzbSettings.raml) | Allowlisted projection; no raw secrets. |
| 278 | [POST `/api/v1/Settings/DogNzb`](../api/raml/api.raml#L5070) | P | `ombi_settings_write / patch:dognzb` | body [DogNzbSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.DogNzbSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 279 | [POST `/api/v1/Settings/SickRage`](../api/raml/api.raml#L5100) | P | `ombi_settings_write / patch:sickrage` | body [SickRageSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SickRageSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 280 | [GET `/api/v1/Settings/SickRage`](../api/raml/api.raml#L5100) | D | `ombi_settings_read / sickrage` | none documented | 200: [SickRageSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SickRageSettings.raml) | Allowlisted projection; no raw secrets. |
| 281 | [GET `/api/v1/Settings/jobs`](../api/raml/api.raml#L5130) | D | `ombi_settings_read / jobs` | none documented | 200: [JobSettings](../api/raml/types/Ombi.Settings.Settings.Models.JobSettings.raml) | Allowlisted projection; no raw secrets. |
| 282 | [POST `/api/v1/Settings/jobs`](../api/raml/api.raml#L5130) | P | `ombi_settings_write / patch:jobs` | body [JobSettings](../api/raml/types/Ombi.Settings.Settings.Models.JobSettings.raml) | 200: [JobSettingsViewModel](../api/raml/types/Ombi.Models.JobSettingsViewModel.raml) | Only typed non-secret patch; preserve omitted/private fields. |
| 283 | [POST `/api/v1/Settings/testcron`](../api/raml/api.raml#L5160) | D | `ombi_server / cron_validate` | body [CronViewModelBody](../api/raml/types/Ombi.Models.CronViewModelBody.raml) | 200: [CronTestModel](../api/raml/types/Ombi.Models.CronTestModel.raml) | Administrator-gated read calculation. |
| 284 | [POST `/api/v1/Settings/Issues`](../api/raml/api.raml#L5178) | P | `ombi_settings_write / patch:issues` | body [IssueSettings](../api/raml/types/Ombi.Settings.Settings.Models.IssueSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 285 | [GET `/api/v1/Settings/Issues`](../api/raml/api.raml#L5178) | D | `ombi_settings_read / issues` | none documented | 200: [IssueSettings](../api/raml/types/Ombi.Settings.Settings.Models.IssueSettings.raml) | Allowlisted projection; no raw secrets. |
| 286 | [GET `/api/v1/Settings/issuesenabled`](../api/raml/api.raml#L5208) | D | `ombi_settings_read / issuesenabled` | none documented | 200: boolean | Allowlisted projection; no raw secrets. |
| 287 | [POST `/api/v1/Settings/vote`](../api/raml/api.raml#L5217) | P | `ombi_settings_write / patch:vote` | body [VoteSettings](../api/raml/types/Ombi.Settings.Settings.Models.VoteSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 288 | [GET `/api/v1/Settings/vote`](../api/raml/api.raml#L5217) | D | `ombi_settings_read / vote` | none documented | 200: [VoteSettings](../api/raml/types/Ombi.Settings.Settings.Models.VoteSettings.raml) | Allowlisted projection; no raw secrets. |
| 289 | [GET `/api/v1/Settings/voteenabled`](../api/raml/api.raml#L5247) | D | `ombi_settings_read / voteenabled` | none documented | 200: boolean | Allowlisted projection; no raw secrets. |
| 290 | [POST `/api/v1/Settings/themoviedb`](../api/raml/api.raml#L5256) | P | `ombi_settings_write / patch:themoviedb` | body [TheMovieDbSettings](../api/raml/types/Ombi.Core.Settings.Models.External.TheMovieDbSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 291 | [GET `/api/v1/Settings/themoviedb`](../api/raml/api.raml#L5256) | D | `ombi_settings_read / themoviedb` | none documented | 200: [TheMovieDbSettings](../api/raml/types/Ombi.Core.Settings.Models.External.TheMovieDbSettings.raml) | Allowlisted projection; no raw secrets. |
| 292 | [POST `/api/v1/Settings/notifications/email`](../api/raml/api.raml#L5286) | P | `ombi_settings_write / patch:notifications.email` | body [EmailNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.EmailNotificationsViewModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 293 | [GET `/api/v1/Settings/notifications/email`](../api/raml/api.raml#L5286) | D | `ombi_settings_read / notifications.email` | none documented | 200: [EmailNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.EmailNotificationsViewModel.raml) | Allowlisted projection; no raw secrets. |
| 294 | [GET `/api/v1/Settings/notifications/email/enabled`](../api/raml/api.raml#L5316) | D | `ombi_settings_read / notifications.email.enabled` | none documented | 200: boolean | Allowlisted projection; no raw secrets. |
| 295 | [POST `/api/v1/Settings/notifications/discord`](../api/raml/api.raml#L5327) | P | `ombi_settings_write / patch:notifications.discord` | body [DiscordNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.DiscordNotificationsViewModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 296 | [GET `/api/v1/Settings/notifications/discord`](../api/raml/api.raml#L5327) | D | `ombi_settings_read / notifications.discord` | none documented | 200: [DiscordNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.DiscordNotificationsViewModel.raml) | Allowlisted projection; no raw secrets. |
| 297 | [POST `/api/v1/Settings/notifications/telegram`](../api/raml/api.raml#L5357) | P | `ombi_settings_write / patch:notifications.telegram` | body [TelegramNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.TelegramNotificationsViewModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 298 | [GET `/api/v1/Settings/notifications/telegram`](../api/raml/api.raml#L5357) | D | `ombi_settings_read / notifications.telegram` | none documented | 200: [TelegramNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.TelegramNotificationsViewModel.raml) | Allowlisted projection; no raw secrets. |
| 299 | [POST `/api/v1/Settings/notifications/pushbullet`](../api/raml/api.raml#L5387) | P | `ombi_settings_write / patch:notifications.pushbullet` | body [PushbulletNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.PushbulletNotificationViewModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 300 | [GET `/api/v1/Settings/notifications/pushbullet`](../api/raml/api.raml#L5387) | D | `ombi_settings_read / notifications.pushbullet` | none documented | 200: [PushbulletNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.PushbulletNotificationViewModel.raml) | Allowlisted projection; no raw secrets. |
| 301 | [POST `/api/v1/Settings/notifications/pushover`](../api/raml/api.raml#L5417) | P | `ombi_settings_write / patch:notifications.pushover` | body [PushoverNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.PushoverNotificationViewModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 302 | [GET `/api/v1/Settings/notifications/pushover`](../api/raml/api.raml#L5417) | D | `ombi_settings_read / notifications.pushover` | none documented | 200: [PushoverNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.PushoverNotificationViewModel.raml) | Allowlisted projection; no raw secrets. |
| 303 | [POST `/api/v1/Settings/notifications/slack`](../api/raml/api.raml#L5447) | P | `ombi_settings_write / patch:notifications.slack` | body [SlackNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.SlackNotificationsViewModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 304 | [GET `/api/v1/Settings/notifications/slack`](../api/raml/api.raml#L5447) | D | `ombi_settings_read / notifications.slack` | none documented | 200: [SlackNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.SlackNotificationsViewModel.raml) | Allowlisted projection; no raw secrets. |
| 305 | [POST `/api/v1/Settings/notifications/mattermost`](../api/raml/api.raml#L5477) | P | `ombi_settings_write / patch:notifications.mattermost` | body [MattermostNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.MattermostNotificationsViewModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 306 | [GET `/api/v1/Settings/notifications/mattermost`](../api/raml/api.raml#L5477) | D | `ombi_settings_read / notifications.mattermost` | none documented | 200: [MattermostNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.MattermostNotificationsViewModel.raml) | Allowlisted projection; no raw secrets. |
| 307 | [GET `/api/v1/Settings/notifications/twilio`](../api/raml/api.raml#L5507) | D | `ombi_settings_read / notifications.twilio` | none documented | 200: [TwilioSettingsViewModel](../api/raml/types/Ombi.Core.Models.UI.TwilioSettingsViewModel.raml) | Allowlisted projection; no raw secrets. |
| 308 | [POST `/api/v1/Settings/notifications/twilio`](../api/raml/api.raml#L5507) | P | `ombi_settings_write / patch:notifications.twilio` | body [TwilioSettingsViewModel](../api/raml/types/Ombi.Core.Models.UI.TwilioSettingsViewModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 309 | [POST `/api/v1/Settings/notifications/mobile`](../api/raml/api.raml#L5537) | P | `ombi_settings_write / patch:notifications.mobile` | body [MobileNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.MobileNotificationsViewModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 310 | [GET `/api/v1/Settings/notifications/mobile`](../api/raml/api.raml#L5537) | D | `ombi_settings_read / notifications.mobile` | none documented | 200: [MobileNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.MobileNotificationsViewModel.raml) | Allowlisted projection; no raw secrets. |
| 311 | [POST `/api/v1/Settings/notifications/gotify`](../api/raml/api.raml#L5567) | P | `ombi_settings_write / patch:notifications.gotify` | body [GotifyNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.GotifyNotificationViewModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 312 | [GET `/api/v1/Settings/notifications/gotify`](../api/raml/api.raml#L5567) | D | `ombi_settings_read / notifications.gotify` | none documented | 200: [GotifyNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.GotifyNotificationViewModel.raml) | Allowlisted projection; no raw secrets. |
| 313 | [POST `/api/v1/Settings/notifications/ntfy`](../api/raml/api.raml#L5597) | P | `ombi_settings_write / patch:notifications.ntfy` | body [NtfyNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.NtfyNotificationViewModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 314 | [GET `/api/v1/Settings/notifications/ntfy`](../api/raml/api.raml#L5597) | D | `ombi_settings_read / notifications.ntfy` | none documented | 200: [NtfyNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.NtfyNotificationViewModel.raml) | Allowlisted projection; no raw secrets. |
| 315 | [POST `/api/v1/Settings/notifications/webhook`](../api/raml/api.raml#L5627) | P | `ombi_settings_write / patch:notifications.webhook` | body [WebhookNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.WebhookNotificationViewModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 316 | [GET `/api/v1/Settings/notifications/webhook`](../api/raml/api.raml#L5627) | D | `ombi_settings_read / notifications.webhook` | none documented | 200: [WebhookNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.WebhookNotificationViewModel.raml) | Allowlisted projection; no raw secrets. |
| 317 | [POST `/api/v1/Settings/notifications/newsletter`](../api/raml/api.raml#L5657) | P | `ombi_settings_write / patch:notifications.newsletter` | body [NewsletterNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.NewsletterNotificationViewModel.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 318 | [GET `/api/v1/Settings/notifications/newsletter`](../api/raml/api.raml#L5657) | D | `ombi_settings_read / notifications.newsletter` | none documented | 200: [NewsletterNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.NewsletterNotificationViewModel.raml) | Allowlisted projection; no raw secrets. |
| 319 | [POST `/api/v1/Sonarr/Profiles`](../api/raml/api.raml#L5687) | A | `ombi_integration_read / options:sonarr/profiles` | body [SonarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SonarrSettings.raml) | 200: array&lt;[SonarrProfile](../api/raml/types/Ombi.Api.External.ExternalApis.Sonarr.Models.SonarrProfile.raml)&gt; | Private saved settings only; no connection overrides. |
| 320 | [GET `/api/v1/Sonarr/Profiles`](../api/raml/api.raml#L5687) | D | `ombi_integration_read / options:sonarr/profiles` | none documented | 200: array&lt;[SonarrProfile](../api/raml/types/Ombi.Api.External.ExternalApis.Sonarr.Models.SonarrProfile.raml)&gt; | — |
| 321 | [POST `/api/v1/Sonarr/RootFolders`](../api/raml/api.raml#L5719) | A | `ombi_integration_read / options:sonarr/root_folders` | body [SonarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SonarrSettings.raml) | 200: array&lt;[SonarrRootFolder](../api/raml/types/Ombi.Api.External.ExternalApis.Sonarr.Models.SonarrRootFolder.raml)&gt; | Private saved settings only; no connection overrides. |
| 322 | [GET `/api/v1/Sonarr/RootFolders`](../api/raml/api.raml#L5719) | D | `ombi_integration_read / options:sonarr/root_folders` | none documented | 200: array&lt;[SonarrRootFolder](../api/raml/types/Ombi.Api.External.ExternalApis.Sonarr.Models.SonarrRootFolder.raml)&gt; | — |
| 323 | [GET `/api/v1/Sonarr/v3/LanguageProfiles`](../api/raml/api.raml#L5751) | D | `ombi_integration_read / options:sonarr/language_profiles` | none documented | 200: array&lt;[LanguageProfiles](../api/raml/types/Ombi.Api.External.ExternalApis.Sonarr.Models.V3.LanguageProfiles.raml)&gt; | — |
| 324 | [POST `/api/v1/Sonarr/v3/LanguageProfiles`](../api/raml/api.raml#L5751) | A | `ombi_integration_read / options:sonarr/language_profiles` | body [SonarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SonarrSettings.raml) | 200: array&lt;[LanguageProfiles](../api/raml/types/Ombi.Api.External.ExternalApis.Sonarr.Models.V3.LanguageProfiles.raml)&gt; | Private saved settings only; no connection overrides. |
| 325 | [POST `/api/v1/Sonarr/tags`](../api/raml/api.raml#L5783) | A | `ombi_integration_read / options:sonarr/tags` | body [SonarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SonarrSettings.raml) | 200: array&lt;[Tag](../api/raml/types/Ombi.Api.External.ExternalApis.Sonarr.Models.Tag.raml)&gt; | Private saved settings only; no connection overrides. |
| 326 | [GET `/api/v1/Sonarr/tags`](../api/raml/api.raml#L5783) | D | `ombi_integration_read / options:sonarr/tags` | none documented | 200: array&lt;[Tag](../api/raml/types/Ombi.Api.External.ExternalApis.Sonarr.Models.Tag.raml)&gt; | — |
| 327 | [GET `/api/v1/Sonarr/enabled`](../api/raml/api.raml#L5815) | D | `ombi_integration_read / options:sonarr/enabled` | none documented | 200: boolean | — |
| 328 | [GET `/api/v1/Sonarr/version`](../api/raml/api.raml#L5824) | D | `ombi_integration_read / options:sonarr/version` | none documented | 200: string | — |
| 329 | [GET `/api/v1/Stats`](../api/raml/api.raml#L5833) | D | `ombi_server / stats` | query `from`:string optional; query `to`:string optional | 200: [UserStatsSummary](../api/raml/types/Ombi.Core.Engine.UserStatsSummary.raml) | from/to query parameters are supported. |
| 330 | [GET `/api/v1/Status`](../api/raml/api.raml#L5849) | D | `ombi_server / status` | none documented | 200: [HttpStatusCode](../api/raml/types/System.Net.HttpStatusCode.raml) | — |
| 331 | [GET `/api/v1/Status/info`](../api/raml/api.raml#L5860) | D | `ombi_server / status_info` | none documented | 200: string | — |
| 332 | [GET `/api/v2/System/news`](../api/raml/api.raml#L5871) | D | `ombi_server / news` | none documented | 200: body unspecified | — |
| 333 | [GET `/api/v2/System/logs`](../api/raml/api.raml#L5877) | D | `ombi_logs / list` | none documented | 200: body unspecified | — |
| 334 | [GET `/api/v2/System/logs/{logFileName}`](../api/raml/api.raml#L5883) | D | `ombi_logs / read` | path `logFileName`:string required | 200: body unspecified | Vetted opaque ID maps to filename; sanitized bounded local slicing. |
| 335 | [GET `/api/v2/System/logs/download/{logFileName}`](../api/raml/api.raml#L5893) | X | `raw diagnostic download` | path `logFileName`:string required | 200: body unspecified | May expose secrets; sanitized logs tool is the supported alternative. |
| 336 | [POST `/api/v1/Tester/discord`](../api/raml/api.raml#L5903) | P | `ombi_integration_test / discord` | body [DiscordNotificationSettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.DiscordNotificationSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 337 | [POST `/api/v1/Tester/pushbullet`](../api/raml/api.raml#L5923) | P | `ombi_integration_test / pushbullet` | body [PushbulletSettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.PushbulletSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 338 | [POST `/api/v1/Tester/pushover`](../api/raml/api.raml#L5943) | P | `ombi_integration_test / pushover` | body [PushoverSettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.PushoverSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 339 | [POST `/api/v1/Tester/gotify`](../api/raml/api.raml#L5963) | P | `ombi_integration_test / gotify` | body [GotifySettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.GotifySettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 340 | [POST `/api/v1/Tester/ntfy`](../api/raml/api.raml#L5983) | P | `ombi_integration_test / ntfy` | body [NtfySettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.NtfySettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 341 | [POST `/api/v1/Tester/webhook`](../api/raml/api.raml#L6003) | P | `ombi_integration_test / webhook` | body [WebhookSettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.WebhookSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 342 | [POST `/api/v1/Tester/mattermost`](../api/raml/api.raml#L6023) | P | `ombi_integration_test / mattermost` | body [MattermostNotificationSettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.MattermostNotificationSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 343 | [POST `/api/v1/Tester/slack`](../api/raml/api.raml#L6043) | P | `ombi_integration_test / slack` | body [SlackNotificationSettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.SlackNotificationSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 344 | [POST `/api/v1/Tester/email`](../api/raml/api.raml#L6063) | P | `ombi_integration_test / email` | body [EmailNotificationSettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.EmailNotificationSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 345 | [POST `/api/v1/Tester/plex`](../api/raml/api.raml#L6083) | P | `ombi_integration_test / plex` | body [PlexServers](../api/raml/types/Ombi.Core.Settings.Models.External.PlexServers.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 346 | [POST `/api/v1/Tester/emby`](../api/raml/api.raml#L6103) | P | `ombi_integration_test / emby` | body [EmbyServers](../api/raml/types/Ombi.Core.Settings.Models.External.EmbyServers.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 347 | [POST `/api/v1/Tester/jellyfin`](../api/raml/api.raml#L6123) | P | `ombi_integration_test / jellyfin` | body [JellyfinServers](../api/raml/types/Ombi.Core.Settings.Models.External.JellyfinServers.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 348 | [POST `/api/v1/Tester/radarr`](../api/raml/api.raml#L6143) | P | `ombi_integration_test / radarr` | body [RadarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.RadarrSettings.raml) | 200: [TesterResultModel](../api/raml/types/Ombi.Core.Models.TesterResultModel.raml) | Saved authorized profile only; exact tester body differs by service. |
| 349 | [POST `/api/v1/Tester/sonarr`](../api/raml/api.raml#L6163) | P | `ombi_integration_test / sonarr` | body [SonarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SonarrSettings.raml) | 200: [TesterResultModel](../api/raml/types/Ombi.Core.Models.TesterResultModel.raml) | Saved authorized profile only; exact tester body differs by service. |
| 350 | [POST `/api/v1/Tester/couchpotato`](../api/raml/api.raml#L6183) | P | `ombi_integration_test / couchpotato` | body [CouchPotatoSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.CouchPotatoSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 351 | [POST `/api/v1/Tester/telegram`](../api/raml/api.raml#L6203) | P | `ombi_integration_test / telegram` | body [TelegramSettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.TelegramSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 352 | [POST `/api/v1/Tester/sickrage`](../api/raml/api.raml#L6223) | P | `ombi_integration_test / sickrage` | body [SickRageSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SickRageSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 353 | [POST `/api/v1/Tester/newsletter`](../api/raml/api.raml#L6243) | P | `ombi_integration_test / newsletter` | body [NewsletterNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.NewsletterNotificationViewModel.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 354 | [POST `/api/v1/Tester/mobile`](../api/raml/api.raml#L6261) | P | `ombi_integration_test / mobile` | body [MobileNotificationTestViewModel](../api/raml/types/Ombi.Models.MobileNotificationTestViewModel.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 355 | [POST `/api/v1/Tester/lidarr`](../api/raml/api.raml#L6279) | P | `ombi_integration_test / lidarr` | body [LidarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.LidarrSettings.raml) | 200: [TesterResultModel](../api/raml/types/Ombi.Core.Models.TesterResultModel.raml) | Saved authorized profile only; exact tester body differs by service. |
| 356 | [POST `/api/v1/Tester/whatsapp`](../api/raml/api.raml#L6297) | P | `ombi_integration_test / whatsapp` | body [WhatsAppSettingsViewModel](../api/raml/types/Ombi.Core.Models.UI.WhatsAppSettingsViewModel.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 357 | [GET `/api/v1/TheMovieDb/Keywords`](../api/raml/api.raml#L6315) | D | `ombi_reference / keywords` | query `searchTerm`:unspecified optional | 200: array&lt;[TheMovidDbKeyValue](../api/raml/types/Ombi.Api.External.ExternalApis.TheMovieDb.Models.TheMovidDbKeyValue.raml)&gt; | — |
| 358 | [GET `/api/v1/TheMovieDb/Keywords/{keywordId}`](../api/raml/api.raml#L6332) | D | `ombi_reference / keyword` | path `keywordId`:unspecified required | 200: body unspecified | — |
| 359 | [GET `/api/v1/TheMovieDb/WatchProviders/movie`](../api/raml/api.raml#L6345) | D | `ombi_reference / watch_providers` | query `searchTerm`:unspecified optional | 200: array&lt;[WatchProvidersResults](../api/raml/types/Ombi.Api.External.ExternalApis.TheMovieDb.Models.WatchProvidersResults.raml)&gt; | — |
| 360 | [GET `/api/v1/TheMovieDb/WatchProviders/tv`](../api/raml/api.raml#L6362) | D | `ombi_reference / watch_providers` | query `searchTerm`:unspecified optional | 200: array&lt;[WatchProvidersResults](../api/raml/types/Ombi.Api.External.ExternalApis.TheMovieDb.Models.WatchProvidersResults.raml)&gt; | — |
| 361 | [POST `/api/v1/Token`](../api/raml/api.raml#L6379) | I | `upstream authentication adapter` | body [UserAuthModel](../api/raml/types/Ombi.Models.UserAuthModel.raml) | 200: [Token](../api/raml/types/Ombi.Controllers.V1.Token.raml) | Not model-callable; optional auth flow must be verified. |
| 362 | [POST `/api/v1/Token/plextoken`](../api/raml/api.raml#L6405) | I | `upstream authentication adapter` | body [PlexTokenAuthentication](../api/raml/types/Ombi.Models.External.PlexTokenAuthentication.raml) | success unspecified | Not model-callable; optional auth flow must be verified. |
| 363 | [GET `/api/v1/Token/{pinId}`](../api/raml/api.raml#L6431) | I | `upstream authentication adapter` | path `pinId`:integer required | success unspecified | Not model-callable; optional auth flow must be verified. |
| 364 | [POST `/api/v1/Token/refresh`](../api/raml/api.raml#L6444) | I | `upstream authentication adapter` | body [TokenController_TokenRefresh](../api/raml/types/Ombi.Controllers.V1.TokenController_TokenRefresh.raml) | success unspecified | Not model-callable; optional auth flow must be verified. |
| 365 | [POST `/api/v1/Token/requirePassword`](../api/raml/api.raml#L6464) | I | `upstream authentication adapter` | body [UserAuthModel](../api/raml/types/Ombi.Models.UserAuthModel.raml) | 200: boolean | Not model-callable; optional auth flow must be verified. |
| 366 | [POST `/api/v1/Token/header_auth`](../api/raml/api.raml#L6482) | I | `upstream authentication adapter` | none documented | 200: body unspecified | Not model-callable; optional auth flow must be verified. |
| 367 | [GET `/api/v1/Update`](../api/raml/api.raml#L6494) | D | `ombi_server / update_info` | none documented | 200: [UpdateModel](../api/raml/types/Ombi.Core.Processor.UpdateModel.raml) | — |
| 368 | [GET `/api/v1/Vote`](../api/raml/api.raml#L6503) | D | `ombi_votes / list` | none documented | 200: array&lt;[VoteViewModel](../api/raml/types/Ombi.Core.Models.UI.VoteViewModel.raml)&gt; | — |
| 369 | [POST `/api/v1/Vote/up/movie/{requestId}`](../api/raml/api.raml#L6515) | D | `ombi_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 370 | [POST `/api/v1/Vote/up/tv/{requestId}`](../api/raml/api.raml#L6530) | D | `ombi_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 371 | [POST `/api/v1/Vote/up/album/{requestId}`](../api/raml/api.raml#L6545) | D | `ombi_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 372 | [POST `/api/v1/Vote/down/movie/{requestId}`](../api/raml/api.raml#L6560) | D | `ombi_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 373 | [POST `/api/v1/Vote/down/tv/{requestId}`](../api/raml/api.raml#L6575) | D | `ombi_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 374 | [POST `/api/v1/Vote/down/album/{requestId}`](../api/raml/api.raml#L6590) | D | `ombi_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 375 | [GET `/api/v1/Vote/movie/{requestId}`](../api/raml/api.raml#L6605) | D | `ombi_votes / get` | path `requestId`:integer required | 200: array&lt;[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)&gt; | — |
| 376 | [GET `/api/v1/Vote/music/{requestId}`](../api/raml/api.raml#L6621) | D | `ombi_votes / get` | path `requestId`:integer required | 200: array&lt;[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)&gt; | — |
| 377 | [GET `/api/v1/Vote/tv/{requestId}`](../api/raml/api.raml#L6637) | D | `ombi_votes / get` | path `requestId`:integer required | 200: array&lt;[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)&gt; | — |
-221
View File
@@ -1,221 +0,0 @@
# Evidence gaps and acceptance criteria
## What this review establishes
The review reads all six earlier design documents, inventories every resource/method in `api.raml`, follows the request/response type references relevant to the tool contracts and inspects the authentication security scheme. The complete inventory is **377 operations, 321 paths**. The replacement accounts for 257 direct mappings, 37 alternative routes, 59 partial mappings, 12 internal operations and 12 exclusions. These are design dispositions, not live integration-test results.
The input catalogue contains all 31 proposed tool schemas; it does not stop at the three most complex tools. The output contract binds each of those 31 operations to specific result families. Administrative patch schemas reference 33 writable settings sections; settings reads cover 41 sections. Feature writes are separate actions and not counted as a settings section.
No application code, runtime dependency declaration, credentials, existing draft or RAML file is changed. No live Ombi mutation is performed. Source-specific verification remains necessary before implementing or enabling gated branches.
## Checks completed on these documents
- Parsed the embedded JSON and validated all 31 input schemas plus the common output schema using `jsonschema` 4.25.1's Draft202012Validator. The validator was installed in a temporary directory, not added to this project.
- Validated 217 positive branch/result examples, including one minimal input per branch and success/error results for every tool. Rejected 568 negative cases covering missing required fields, unexpected properties and representative invalid media/action combinations. These checks establish schema consistency, not upstream runtime semantics.
- Compared the ledger's unique method/path set directly with the parsed RAML: exact equality, 377 operations, 321 distinct paths, no omissions or duplicates. Every published tool has an operation owner in the ledger.
- Checked local Markdown links and all local `$ref` targets, and confirmed the new documents contain no environment-specific instance URL.
- Reproduced the earlier drafts' schema issues with a validator: SWE `read_requests` with only `mediaType=movie` unexpectedly requires requestId and query; SWE accepts TV with both external IDs and an empty selection; MED accepts movie creation without its body and issue creation without its body.
The schemas deliberately retain runtime checks for semantics JSON Schema cannot establish: upstream identity, permissions, available profiles, provider-ID provenance, duplicate season-number keys, no-op patches, array identity preservation and request outcome reconciliation.
## Source defects and semantic gaps
| Gap | Evidence | Required decision / verification |
|---|---|---|
| Authentication model | ApiKey scheme is global; Token routes also appear beneath it | Establish login exceptions, Bearer support and permission model separately; keep API-key baseline |
| API-key principal | Security scheme only names a header | Determine effective user, quotas, on-behalf rights and required permissions; do not infer admin or anonymous identity |
| Enum labels | IssueStatus `[0,1,2,3]`, RequestType `[0,1,2]`, VoteType/RequestSource numeric only | Verify symbolic names from authoritative controller/enum source for supported versions; preserve raw codes meanwhile |
| TV request identity | v1 request body has tvDbId; v2 has theMovieDbId | Keep provider-specific create branches; never substitute one ID namespace for another |
| TV result granularity | v2 TV list wraps ChildRequests; v1 wraps TvRequests | Preserve target kind and parent ID; no silent fallback between units |
| TV moderation/options/subscriptions/details | Several models/routes only say `id` or `requestId` | Verify each controller's accepted parent/child namespace, independently per operation |
| On behalf | requestOnBehalf is just string | Confirm ID versus username and permissions; resolve the public user ID internally if necessary |
| Whole-season semantics | Season/episode properties optional; no empty-list contract | Use explicit episodes; do not infer empty means all |
| Request-type filtering | Legacy order/status/availability parameters are unlabeled integers | Establish finite maps before any legacy filtered adapter; reject unsupported combinations |
| Path/parameter mismatches | IMDb imdbid/imdbId; TV tvdbId/tvdbid; streaming movieDbId/movieDBId | Substitute literal path placeholders while preserving parameter meaning |
| Legacy TV filter mismatch | Path names statusFilterType/availabilityFilterType plus separate statusType/availabilityType parameters | Verify the actual wire behaviour; do not assume these duplicate-looking parameters are interchangeable |
| Folded type declarations | Some descriptions contain text such as `type: integer` instead of a YAML type field | Treat human text as evidence of intent, not valid machine typing; adapter/public schema must state its choice |
| Radarr tags POST | Body is declared SonarrSettings | Prefer existing GET; verify the POST rather than silently correcting RAML |
| Response omissions | Images, user-country list, provider issues, status info, server logs and some integration methods omit schemas | Inspect actual supported-version output and use an allowlisted projection; unknown structures fail closed |
| Integration side effects | Some POST credential/setup endpoints lack descriptions | Keep outside read tools until behaviour is established; no claim that every POST lookup is harmless |
| Collection creation | No body, single RequestEngineResult response | No unsupported modifiers; verify partial success/retry semantics and do not fabricate per-item results |
| Refresh model | token/userename, no refresh-token field | Verify typo and semantics; no invented refresh token |
| Pagination | Ambiguous prose and plain arrays mixed with paged wrappers | Validate units/offset semantics/total fields per route; no invented totals |
| Update and test results | Boolean, model and unspecified responses differ | Interpret per operation, not one universal tester or mutation shape |
| Request retry | GET queue and DELETE entry only | Reprocess existing request via v2 when supported; no synthetic POST queue route |
| Settings replacement | POST section bodies, no documented PATCH/ETag | Private merge + revision check; report residual race with external writers |
| Deployment paths | RAML contains a fixed baseUri | Never use it as a distributable default or public fixture; preserve configured reverse-proxy path prefixes |
## Acceptance criteria for an implementation
These are future checks, not claims that an implementation was written or tested here.
### Schema and routing
- Validate every published schema with JSON Schema 2020-12 and exercise each branch with both a valid object and common invalid combinations.
- An empty object must not satisfy a request-creation, issue-creation, moderation or delete schema. Optional defaults must not trigger unrelated conditional requirements.
- Movie, TVDB TV, TMDB TV, album and collection creation must send the exact wire body and method. TV has no is4kRequest, album has no requestOnBehalf, and collection has no invented body.
- Denial uses PUT for all media. Similar and actor searches use POST. Lidarr Metadata uses POST. User detail uses Identity/User/{id}. No POST RequestRetry is emitted.
- Request list rejects album+unavailable, preserves TV child identity, maps request_date to requestDate and verifies local versus upstream pagination metadata.
- Reject empty explicit episode lists, duplicate seasons/episodes, ambiguous providers, irrelevant action properties and overflowing request budgets before upstream calls.
- Check every method/path pair against the ledger, including spelling/case and request/response types. Generated brace expansion must never add routes.
### Error and output behaviour
- Handle HTTP 200 business failures, Boolean tester failures, empty-success bodies, missing optional fields and unknown response structures independently.
- Confirm structuredContent validates, text fallback contains the same bounded projection and MCP isError agrees with ok.
- Do not infer success from result ID presence or a timeout; expose UNKNOWN_OUTCOME where execution may already have occurred.
- Verify safe errors for upstream 401/403/404/429/5xx, network errors, invalid JSON and malformed/oversized bodies.
- Check list pages with zero items, unknown totals, truncated arrays and concurrent insertion/deletion. Do not offer a false continuation offset.
- Test nested secrets in requests, issues, stats, user records and error bodies, not just settings.
### Authorization and effects
- Apply core/moderation/administration and branch policy both at tools/list and call time. Annotation values cannot grant permissions.
- Confirm the effective upstream principal and another-user access explicitly. API key mode must not accidentally promise per-client user isolation.
- Keep one shared HTTP client, encode path/query values, preserve configured prefixes and suppress cross-origin credential forwarding.
- Ensure authentication recovery cannot replay ambiguous writes; capability probes must never trigger jobs or writes.
- Preserve settings secrets and omitted values, reject stale revisions, reject unsafe array replacements and disclose external-writer race limits.
- Verify email recipients, welcome-email target, collection scope, TV parent-delete effects and each enabled job's scope before mutation.
- Never expose credential acquisition, key rotation, arbitrary HTTP, raw entity replacement or raw logs through an accidental fallback branch.
## Review examples
These are illustrative arguments with placeholder IDs, not commands executed against a server. Actual IDs and revision/profile references must come from authorized reads. Numeric examples do not assert real media identities. The TV moderation example requires a verified adapter that establishes ID 456 as a child request.
### Mixed search
Tool: `ombi_search`.
```json
{
"action": "multi",
"query": "Example title",
"include": [
"movies",
"tv_shows"
]
}
```
### TMDB TV details
Tool: `ombi_media`.
```json
{
"action": "details",
"target": {
"media": "tv",
"provider": "tmdb",
"id": 123
}
}
```
### Explicit TV episode request
Tool: `ombi_request_create`.
```json
{
"action": "tv",
"provider": "tmdb",
"id": 123,
"selection": {
"mode": "episodes",
"seasons": [
{
"season_number": 1,
"episodes": [
1,
2
]
}
]
}
}
```
### TV child moderation
Tool: `ombi_request_moderate`.
```json
{
"action": "approve",
"media": "tv",
"request_id": 456
}
```
### Movie 4K denial
Tool: `ombi_request_moderate`.
```json
{
"action": "deny",
"media": "movie",
"request_id": 789,
"is_4k": true,
"reason": "Not currently accepting 4K requests."
}
```
### Album request
Tool: `ombi_request_create`.
```json
{
"action": "album",
"musicbrainz_id": "example-release-group-id"
}
```
### Issue comment
Tool: `ombi_issue_comment`.
```json
{
"issue_id": 123,
"comment": "Playback fails at the same point on a second device."
}
```
### Change a setting
Tool: `ombi_settings_write`.
```json
{
"action": "patch",
"section": "ombi",
"revision": "example-revision-from-settings-read",
"changes": {
"hideRequestsUsers": true
}
}
```
### Saved notification test
Tool: `ombi_integration_test`.
```json
{
"service": "discord",
"profile_id": "example-authorized-saved-profile"
}
```
## Primary references
- [Local RAML API](../api/raml/api.raml) and [API-key security scheme](../api/raml/securitySchemes/api_key.raml) define the upstream evidence boundary.
- [MCP tools, 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) defines registration and tool result behaviour.
- [MCP schema reference](https://modelcontextprotocol.io/specification/2025-11-25/schema) defines annotation and message types.
- [MCP authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) distinguishes transport authorization from upstream credentials.
- [MCP resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) supports optional bounded reference/artwork resources.
The protocol baseline is intentionally pinned; consult the applicable version when choosing a newer transport implementation. RAML corrections or live-server observations should be recorded as versioned adapter evidence rather than silently editing the meaning of this snapshot.
-68
View File
@@ -1,68 +0,0 @@
# Administrative settings type registry
`ombi_settings_write` uses the exact closed projected types embedded in its input schema. This table binds each section to the upstream body type. A patch is merged into an internally loaded full section, never posted as a partial upstream replacement. All nested object patches merge by property; arrays replace the complete array and are validated as complete elements against the saved section/type. Omitted fields preserve their values; null is rejected. An empty nested object is a no-op and must not be treated as deletion. Reject patches that make no effective change.
`revision` is a server-issued opaque digest bound to principal, instance and section. Recheck immediately before save, serialize local saves and reject stale revisions. RAML has no ETag or compare-and-swap guarantee, so external writers can still race; report this limit. Any array element needing hidden fields or identity values must be matched unambiguously to a saved element by a documented safe key; if impossible, reject that array edit. Never guess indexes after concurrent changes.
These types deliberately exclude credentials, connection destinations, internal persistence IDs, migration flags and script/service execution configuration. Such changes remain manual. Enums retain RAML numeric values because labels are not supplied. Fields like `enabled`, schedules, HTML/CSS, templates, default roles and automatic deletion can still have major effects; administrator authorization is required, and job/configuration consequences must be described.
| Section | Upstream POST path | Upstream body type |
|---|---|---|
| `custom_page` | `/api/v1/CustomPage` | [Ombi.Settings.Settings.Models.CustomPageSettings](../api/raml/types/Ombi.Settings.Settings.Models.CustomPageSettings.raml) |
| `ombi` | `/api/v1/Settings/ombi` | [Ombi.Settings.Settings.Models.OmbiSettings](../api/raml/types/Ombi.Settings.Settings.Models.OmbiSettings.raml) |
| `plex` | `/api/v1/Settings/plex` | [Ombi.Core.Settings.Models.External.PlexSettings](../api/raml/types/Ombi.Core.Settings.Models.External.PlexSettings.raml) |
| `emby` | `/api/v1/Settings/emby` | [Ombi.Core.Settings.Models.External.EmbySettings](../api/raml/types/Ombi.Core.Settings.Models.External.EmbySettings.raml) |
| `jellyfin` | `/api/v1/Settings/jellyfin` | [Ombi.Core.Settings.Models.External.JellyfinSettings](../api/raml/types/Ombi.Core.Settings.Models.External.JellyfinSettings.raml) |
| `landingpage` | `/api/v1/Settings/landingpage` | [Ombi.Core.Settings.Models.LandingPageSettings](../api/raml/types/Ombi.Core.Settings.Models.LandingPageSettings.raml) |
| `customization` | `/api/v1/Settings/customization` | [Ombi.Settings.Settings.Models.CustomizationSettings](../api/raml/types/Ombi.Settings.Settings.Models.CustomizationSettings.raml) |
| `sonarr` | `/api/v1/Settings/sonarr` | [Ombi.Settings.Settings.Models.External.SonarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SonarrSettings.raml) |
| `radarr` | `/api/v1/Settings/radarr` | [Ombi.Settings.Settings.Models.External.RadarrCombinedModel](../api/raml/types/Ombi.Settings.Settings.Models.External.RadarrCombinedModel.raml) |
| `lidarr` | `/api/v1/Settings/lidarr` | [Ombi.Settings.Settings.Models.External.LidarrSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.LidarrSettings.raml) |
| `authentication` | `/api/v1/Settings/authentication` | [Ombi.Settings.Settings.Models.AuthenticationSettings](../api/raml/types/Ombi.Settings.Settings.Models.AuthenticationSettings.raml) |
| `update` | `/api/v1/Settings/Update` | [Ombi.Settings.Settings.Models.UpdateSettings](../api/raml/types/Ombi.Settings.Settings.Models.UpdateSettings.raml) |
| `user_management` | `/api/v1/Settings/UserManagement` | [Ombi.Settings.Settings.Models.UserManagementSettings](../api/raml/types/Ombi.Settings.Settings.Models.UserManagementSettings.raml) |
| `couchpotato` | `/api/v1/Settings/CouchPotato` | [Ombi.Settings.Settings.Models.External.CouchPotatoSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.CouchPotatoSettings.raml) |
| `dognzb` | `/api/v1/Settings/DogNzb` | [Ombi.Settings.Settings.Models.External.DogNzbSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.DogNzbSettings.raml) |
| `sickrage` | `/api/v1/Settings/SickRage` | [Ombi.Settings.Settings.Models.External.SickRageSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SickRageSettings.raml) |
| `jobs` | `/api/v1/Settings/jobs` | [Ombi.Settings.Settings.Models.JobSettings](../api/raml/types/Ombi.Settings.Settings.Models.JobSettings.raml) |
| `issues` | `/api/v1/Settings/Issues` | [Ombi.Settings.Settings.Models.IssueSettings](../api/raml/types/Ombi.Settings.Settings.Models.IssueSettings.raml) |
| `vote` | `/api/v1/Settings/vote` | [Ombi.Settings.Settings.Models.VoteSettings](../api/raml/types/Ombi.Settings.Settings.Models.VoteSettings.raml) |
| `themoviedb` | `/api/v1/Settings/themoviedb` | [Ombi.Core.Settings.Models.External.TheMovieDbSettings](../api/raml/types/Ombi.Core.Settings.Models.External.TheMovieDbSettings.raml) |
| `notifications.email` | `/api/v1/Settings/notifications/email` | [Ombi.Core.Models.UI.EmailNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.EmailNotificationsViewModel.raml) |
| `notifications.discord` | `/api/v1/Settings/notifications/discord` | [Ombi.Core.Models.UI.DiscordNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.DiscordNotificationsViewModel.raml) |
| `notifications.telegram` | `/api/v1/Settings/notifications/telegram` | [Ombi.Core.Models.UI.TelegramNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.TelegramNotificationsViewModel.raml) |
| `notifications.pushbullet` | `/api/v1/Settings/notifications/pushbullet` | [Ombi.Core.Models.UI.PushbulletNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.PushbulletNotificationViewModel.raml) |
| `notifications.pushover` | `/api/v1/Settings/notifications/pushover` | [Ombi.Core.Models.UI.PushoverNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.PushoverNotificationViewModel.raml) |
| `notifications.slack` | `/api/v1/Settings/notifications/slack` | [Ombi.Core.Models.UI.SlackNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.SlackNotificationsViewModel.raml) |
| `notifications.mattermost` | `/api/v1/Settings/notifications/mattermost` | [Ombi.Core.Models.UI.MattermostNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.MattermostNotificationsViewModel.raml) |
| `notifications.twilio` | `/api/v1/Settings/notifications/twilio` | [Ombi.Core.Models.UI.TwilioSettingsViewModel](../api/raml/types/Ombi.Core.Models.UI.TwilioSettingsViewModel.raml) |
| `notifications.mobile` | `/api/v1/Settings/notifications/mobile` | [Ombi.Core.Models.UI.MobileNotificationsViewModel](../api/raml/types/Ombi.Core.Models.UI.MobileNotificationsViewModel.raml) |
| `notifications.gotify` | `/api/v1/Settings/notifications/gotify` | [Ombi.Core.Models.UI.GotifyNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.GotifyNotificationViewModel.raml) |
| `notifications.ntfy` | `/api/v1/Settings/notifications/ntfy` | [Ombi.Core.Models.UI.NtfyNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.NtfyNotificationViewModel.raml) |
| `notifications.webhook` | `/api/v1/Settings/notifications/webhook` | [Ombi.Core.Models.UI.WebhookNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.WebhookNotificationViewModel.raml) |
| `notifications.newsletter` | `/api/v1/Settings/notifications/newsletter` | [Ombi.Core.Models.UI.NewsletterNotificationViewModel](../api/raml/types/Ombi.Core.Models.UI.NewsletterNotificationViewModel.raml) |
## Read-only sections
| Section | GET path |
|---|---|
| `base_url` | `/api/v1/Settings/baseurl` |
| `client_id` | `/api/v1/Settings/clientid` |
| `default_language` | `/api/v1/Settings/defaultlanguage` |
| `themes` | `/api/v1/Settings/themes` |
| `lidarrenabled` | `/api/v1/Settings/lidarrenabled` |
| `issuesenabled` | `/api/v1/Settings/issuesenabled` |
| `voteenabled` | `/api/v1/Settings/voteenabled` |
| `notifications.email.enabled` | `/api/v1/Settings/notifications/email/enabled` |
## Fields excluded from patches
The following exact field names are excluded recursively wherever encountered; `key` in a selected-library record is an identifier, not automatically a secret. Read projections also exclude private values and use an allowlist, not merely this name list.
`accessToken`, `accountSid`, `administratorId`, `apiKey`, `applicationToken`, `applicationUrl`, `authToken`, `authorizationHeader`, `baseUrl`, `botApi`, `customDonationUrl`, `disableCertificateChecking`, `disableTLS`, `favicon`, `hasMigratedOldTvDbData`, `host`, `iconUrl`, `id`, `installId`, `ip`, `logo`, `machineIdentifier`, `password`, `plexAuthToken`, `port`, `processName`, `scriptLocation`, `serverHostname`, `serverId`, `set`, `ssl`, `subDir`, `useScript`, `userToken`, `webhookUrl`, `windowsService`, `windowsServiceName`, `wizard`.
`notifications.mobile` may have only template fields left after projection. Notification tester bodies differ from settings UI view models: choose the tester body type from the operation ledger and construct it internally; do not forward a settings view model blindly. Profiles for testers without a complete saved-settings route (for example mobile) must be provisioned outside the model.
## Feature writes
`action=feature` sends `{name, enabled}` to `/api/v2/Features/enable` when true and `/disable` when false. The feature name must have been returned by the configured instance; do not invent a universal feature enum. Feature writes are not an arbitrary settings section.
-30
View File
@@ -1,30 +0,0 @@
# Ombi MCP schema — Astra high
This is a documentation-only design and audit of the supplied RAML snapshot. It does not implement a server, contact an Ombi instance, or certify undocumented server behaviour.
The API contains **321 distinct paths and 377 HTTP operations**. Coverage means accounting for every method/path pair, including compatibility routes, internal authentication and deliberate exclusions. It does not mean publishing 377 tools or exposing every administrative capability by default.
Read in this order:
1. [Assessment of both earlier attempts](00-assessment.md).
2. [Authentication, authorization and transport](01-authentication.md).
3. [Tool catalogue and exact routing rules](02-tool-mapping.md).
4. [Complete input schema catalogue](03-input-schemas.md).
5. [Output contracts and MCP behaviour](04-results.md).
6. [All 377 operations and their disposition](05-endpoint-coverage.md).
7. [Uncertainties and acceptance criteria](06-verification.md).
8. [Administrative settings types](07-settings-types.md).
The proposed catalogue separates ordinary media workflows, moderation and optional administration. Only authorized, configured tools and branches should be advertised. Tool count is a consequence of coherent tasks and permission boundaries, not an optimization target by itself.
The catalogue defines **31 tools: 18 core, 5 moderation and 8 administration**. All input schemas and the shared output schema have been validated; the ledger matches all 377 RAML operations exactly. Runtime assumptions are explicitly tracked in the verification document.
MCP interoperability baseline: **2025-11-25**, with explicit JSON Schema 2020-12. This is a deliberate compatibility target, not a claim that it is the newest published revision. A later protocol version requires its own transport/versioning review; the domain design here does not assume later extensions.
Evidence labels used throughout:
- **Documented:** directly supported by a path, description or type in this RAML snapshot.
- **Design:** an intentional MCP contract, stricter validation rule or normalization proposed here.
- **Verify:** semantics absent or ambiguous in RAML; must be confirmed before that branch is enabled.
All URLs come from `OMBI_URL`; the source RAML's environment-specific `baseUri` is never a runtime default. `OMBI_API_KEY` remains the standard upstream credential.
+9 -10
View File
@@ -22,7 +22,7 @@ The [input catalogue](03-input-schemas.md) defines every parameter, required fie
| `read_votes` | Global vote list or votes on a request | core | vote page |
| `read_users` | Self, authorized user lookup, claims, online users, preference read | core | user/reference page |
| `read_library` | Recent additions, calendar, artwork | core | media/calendar/artwork page |
| `read_server` | Status, version, features, news, stats, cron validation | core | metrics/reference page |
| `read_server` | Status, version, features, stats, cron validation | core | metrics/reference page |
| `read_integration` | Saved ARR options and authorized media-server metadata | core | reference/user page |
| `write_request_create` | One media request or explicit collection request | core | mutation |
| `write_request_subscribe` | Subscribe/unsubscribe | core | mutation |
@@ -95,7 +95,7 @@ The [input catalogue](03-input-schemas.md) defines every parameter, required fie
| `welcome_email` | 8 | | | |
Remaining numeric enums are unresolved gaps and stay numeric pending verification: `VoteType [0,1]` (labels unverified), `RequestSource`, `RequestLimitType [0,1,2]`, and the legacy `orderType`/`statusType`/`availabilityType` filter ints. Search response IDs must retain their source/provider namespace, including multi-search TV TMDB IDs.
5. A TV parent is a show record; a TV child is an individual request under it. `read_requests.list(media=tv)` uses v2 child pages. Parent detail and children enumeration use v1. A compact projection of v2 children is preferable to secretly swapping in `tvlite` parent results. Return `parent_request_id` alongside child IDs when present.
5. A TV parent is a show record; a TV child is an individual request under it. `read_requests.list(media=tv)` uses v2 child pages. Parent detail and children enumeration use v1. A compact projection of v2 children is preferable to secretly swapping in `tvlite` parent results (the v2 list contract is preserved; upstream 500 errors surface natively). Return `parent_request_id` alongside child IDs when present. `read_requests.search` for TV uses v1 `Request/tv/search/{term}` but falls back to a bounded v1 parent scan if it fails, compensating for an upstream LINQ bug.
6. Parameter names such as `currentPosition`, `position`, `skip`, `count`, `take` and `amountToLoad` are mapped exactly per ledger. Offsets are zero-based by this MCP contract; the adapter must verify ambiguous upstream paging behaviour. Requests use count then position; issue summary uses position then take; issue list uses take then skip. Do not reverse these pairs.
7. Array responses without server pagination are sliced locally only within a bounded fetched response. Mark pagination as local and total unknown unless the complete collection was obtained. A result-size cap is a truncation warning, not a fabricated server total or a promise that the next page exists.
8. `format: date-time` and cross-field comparisons must be enforced by the server, not assumed from a client's validator. Stats requires `from <= to` when both are supplied. Strings must contain non-whitespace text where used as queries/comments. Reject duplicate season numbers and duplicate episode numbers; impose a maximum of 2,000 selected episodes per call in addition to per-array limits.
@@ -109,11 +109,10 @@ The [input catalogue](03-input-schemas.md) defines every parameter, required fie
`similar` is POST v2 movie/similar with `{theMovieDbId, languageCode?}`. A verified v1 POST equivalent can preserve language; a v1 GET cannot preserve a language argument. `collection` returns the collection's members/basic metadata, without creating requests. `credits` chooses actor/{actorId}/movie or /tv. `artist_albums` uses v1 music/artist/album/{foreignArtistId}.
`advanced_movie` POSTs the exact DiscoverModel property names: `release_year→releaseYear`, `genre_ids→genreIds`, `keyword_ids→keywordIds`, `watch_provider_ids→watchProviders`, `company_ids→companies`, and decade unchanged. Do not invent a `query` requirement for discovery. The optional upstream `type` field is omitted until its semantics are verified; release year and decade must agree if both supplied.
`advanced_movie` POSTs the exact DiscoverModel property names: `type:"movie"`, `release_year→releaseYear`, `genre_ids→genreIds`, `keyword_ids→keywordIds`, `watch_provider_ids→watchProviders`, and decade unchanged. Ombi 4.53.x accepts but ignores `companies`, so `company_ids` is not advertised and legacy calls fail explicitly. Do not invent a `query` requirement for discovery. Release year and decade must agree if both are supplied.
`read_media.details` chooses v2 movie TMDB/IMDb, TV TVDB/TMDB, artist, or artist/album routes. TV `tmdb` uses v2 `Search/tv/moviedb/{id}`; TV `tvdb` uses the legacy v1 `Search/tv/info/{tvdbId}` route — the v2 `Search/tv/{tvdbId}` route is a TMDB-keyed alias of moviedb despite its parameter name and must never serve a TVDB lookup. IMDb path placeholder spelling differs from its parameter declaration; substitute the actual path placeholder. `by_request` chooses movie/request, tv/request or artist/request; the TV namespace of this particular upstream route requires adapter verification and must not be guessed from list results. `movie_localized` uses POST v1 movie/info with `{theMovieDbId, languageCode}`. `ratings` uses title and year, not a numeric media ID. `streaming` uses TMDB even for TV. Cast/crew are projections of detailed metadata where present, not invented standalone endpoints.
TV identifier labels follow the origin route, not the upstream field name. The v1 TVMaze-backed routes (`Search/tv/{term}`, `Search/tv/info/{tvdbId}`) place the TVDB id in the misleadingly-named `theMovieDbId` field and the TVMaze id in `seriesId`; emit `tvdb` and `tvmaze` there. The v2 engine routes (browse, `moviedb`, `by_request`, multi-search) are TMDB-keyed; emit `tmdb`. The v1 `RecentlyAdded` TV model's id namespace is unverified — keep `tmdb` pending live confirmation.
`read_media.details` chooses v2 movie TMDB/IMDb, TV TVDB/TMDB, artist, or artist/album routes. TV `tmdb` uses v2 `Search/tv/moviedb/{id}`; TV `tvdb` uses the legacy v1 `Search/tv/info/{tvdbId}` route (the v2 `Search/tv/{tvdbId}` route is a TMDB-keyed alias and must never serve a TVDB lookup). The legacy v1 TVDB info route does not reliably carry request state; for TVDB, the adapter performs a bounded scan of v1 `Request/tv` (matching by `tvDbId` or `imdbId`) to overlay `requested`, `request_targets` (using the real parent request ID), and per-episode availability flags. IMDb path placeholder spelling differs from its parameter declaration. `by_request` chooses movie/request, tv/request or artist/request. `movie_localized` uses POST v1 movie/info. `ratings` first uses Ombi's title/year ratings route. Ombi 4.53.x still routes that operation through removed Rotten Tomatoes private endpoints; on a 404/5xx (or a successful empty response), the adapter searches the same media/title and requires an exact case-insensitive title and year match before returning explicitly labelled `tmdb_vote_*` or `tvmaze_site_rating` references. A warning identifies the fallback source; no fuzzy match is substituted. `streaming` uses TMDB even for TV.
TV identifier labels follow the origin route, not the upstream field name. The v1 TVMaze-backed routes (`Search/tv/{term}`, `Search/tv/info/{tvdbId}`) place the TVDB id in the misleadingly-named `theMovieDbId` field and the TVMaze id in `seriesId`; emit `tvdb` and `tvmaze` there. The v2 engine routes (browse, `moviedb`, `by_request`, multi-search) are TMDB-keyed; emit `tmdb` and do not treat `seriesId` as TVMaze. When `theMovieDbId` is absent, `id` is labelled with that origin namespace so browse and collection members still carry a usable identifier. Multi-search `mediaType` is case-insensitive (`Artist`→artist). The v1 `RecentlyAdded` TV model's id namespace is unverified — keep `tmdb` pending live confirmation.
`read_reference.keywords` passes query `searchTerm`; keyword detail uses its own keyword ID. Watch-provider catalogue search also has optional `searchTerm`; it is distinct from streaming availability for a particular title. Other branches have no request body. Reference values are good optional cached resources, but remain available through tools for tool-only clients.
@@ -121,7 +120,7 @@ TV identifier labels follow the origin route, not the upstream field name. The v
Use v2 list/status routes, always with sort and page segments. `status` defaults `all`; `sort_direction` maps to the `{sortOrder}` segment (`asc`/`desc`). Public `sort.field=request_date` maps to the documented example `requestDate`; no speculative sort fields are published. `all` means the base route, not an `/all/` segment. Album lacks an unavailable-status route, so that combination fails schema validation. The misspelled movie `availble` route is an explicitly gated compatibility alias, not the primary path.
`get` supports movie and TV parent only; no album single-request endpoint is advertised. `children` returns children for a parent. Request `search` uses the appropriate v1 route and rejects list-only status/sort arguments. `recent` uses v2 recentlyRequested. `retry_queue` is a privileged GET and returns queue IDs separately from underlying request IDs.
`get` supports movie and TV parent only; no album single-request endpoint is advertised. `children` returns children for a parent; child ids are the real upstream child PKs (provider-shaped for first-request children by design), with provider ids surfaced from the embedded `parentRequest`. Request `search` uses the appropriate v1 route and rejects list-only status/sort arguments. `recent` uses v2 recentlyRequested; for movie/album `target.id` is `requestId`, while TV `requestId` is a child request id (often provider-shaped) that is resolved to the parent request id through a bounded v1 parent scan — unresolvable rows emit `target.id` 0 with a warning rather than a provider-shaped target. `retry_queue` is a privileged GET and returns queue IDs separately from underlying request IDs.
`read_request_stats.counts` uses Request/count; `total` uses the media's total endpoint; `quota` uses its remaining endpoint. Quota belongs to the actual upstream principal. `has_requests` requires an explicit `user_id` by MCP policy and sends it as the optional upstream `userId` query; viewing another user is subject to authorization. Do not combine instance totals with a per-user quota under an unlabeled “total”.
@@ -160,7 +159,7 @@ Issue `list` uses v1 paged issues and returns individual records. `summary` uses
Issue creation maps only title, subject, description, `issueCategoryId`, numeric `requestType`, and optional `requestId`/`providerId`. Require at least one association by design; RAML marks these properties optional and does not establish a server requirement. If both are provided, verify they refer to the same media. The server supplies author, timestamps and state; never accept `userReported`, comments, resolved date or persistence ID from the model. Comment POST uses `{comment, issueId}`. Status POST uses `{issueId, status}`. Category POST uses `{value}`. Delete IDs are in the path; no speculative update-category endpoint exists.
Vote reads use `Vote/music/{requestId}` for albums; writes use `Vote/{up|down}/album/{requestId}`. Do not derive both directions from one generic segment rule. Vote toggling/repeated-call behaviour is unverified, hence no idempotence promise.
Vote reads use `Vote/music/{requestId}` for albums; writes use `Vote/{up|down}/album/{requestId}`. Do not derive both directions from one generic segment rule. Vote toggling/repeated-call behaviour is unverified, hence no idempotence promise. On Ombi 4.53.10, the global vote view and movie vote-record route can return HTTP 500 for existing instance data while the corresponding empty TV route succeeds. Ombi exposes no equivalent read route from which the adapter can reconstruct vote ownership or totals. Preserve `UPSTREAM_REJECTED` in that case; never convert the failure into a fabricated empty `vote_page`.
User GET-by-ID is `/Identity/User/{id}`. `self`, all users, dropdown, claims, online and notification preferences each have their exact separate routes. Respect hide-user settings and upstream visibility; do not disclose hidden requester IDs simply to populate a normalized field.
@@ -174,11 +173,11 @@ Recent TV grouped and ungrouped routes are separate branches. Calendar has no do
Images may be binary, redirects or URLs depending on the route; RAML leaves several response bodies unspecified. Return a safe resource reference only after validating actual content type/shape and destination. Do not invent a signed URL or expose an `ApiKey` query. Random backgrounds are read-only but their outputs are not deterministic; idempotent read annotations describe side effects, not identical results.
Stats passes optional `from` and `to` query values. `update_check` is GET Job/update; `update_info` is GET Update. Running updates is an explicit administrative job. `cron_validate` POSTs `{expression}` to Settings/testcron and is an administrator-gated read calculation, not a scheduled-job mutation.
Stats requires RFC3339 `from` and `to` query values. `update_check` is GET Job/update; `update_info` is GET Update. Running updates is an explicit administrative job. `cron_validate` POSTs `{expression}` to Settings/testcron and is an administrator-gated read calculation, not a scheduled-job mutation. Ombi uses Quartz cron syntax: six or seven fields, with `?` in the unused day-of-month or day-of-week field.
`read_integration` prefers saved-settings GETs. Radarr 4K only applies to profiles/root folders, not tags. Sonarr language profiles uses `/v3/LanguageProfiles`. Lidarr Metadata is POST-only, so load saved Lidarr settings privately and construct its request internally. CouchPotato profile is singular and POST-only. Credential acquisition `/CouchPotato/apikey` is never a tool. RPC POST counterparts accepting settings are compatibility adapters to the same read intent; they do not add caller connection overrides.
Plex library lookup uses a known saved `machine_id`; Emby/Jellyfin info and Library POSTs use an authorized saved `server_id` to resolve the server settings privately. No arbitrary server object or connection destination is accepted. Routes that acquire Plex/Emby/Jellyfin access or provision accounts remain internal/manual because their authentication/side effects are not adequately specified here.
Plex library lookup uses a known saved `machine_id` (discoverable via `read_integration plex servers`, which emits the machine identifier in `id`); Emby/Jellyfin info and Library POSTs use an authorized saved `server_id` to resolve the server settings privately (discoverable via `read_settings emby` / `jellyfin` / `plex`, which expose `/servers/<digits>/{id,serverId,machineIdentifier}` leaves in `values`). No arbitrary server object or connection destination is accepted. Routes that acquire Plex/Emby/Jellyfin access or provision accounts remain internal/manual because their authentication/side effects are not adequately specified here.
Settings section names and wire types are fully listed in [the registry](07-settings-types.md). Read-only flags and customization content are not accidentally accepted as writable sections. Before saving, privately read the original section, verify the revision, merge a typed patch preserving secrets and omitted fields, and POST the full correct wire model. An incomplete original object means save is unsupported. Redacted strings must never become stored credentials. Feature writes are enable/disable POSTs with `{name, enabled}`.
+12 -25
View File
@@ -318,6 +318,11 @@ Browse supported lists, related movies, actor credits, artist albums, or advance
"type": "integer",
"minimum": 1
},
"person_name": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"media": {
"type": "string",
"enum": [
@@ -332,6 +337,7 @@ Browse supported lists, related movies, actor credits, artist albums, or advance
"required": [
"action",
"person_id",
"person_name",
"media"
],
"additionalProperties": false
@@ -368,7 +374,7 @@ Browse supported lists, related movies, actor credits, artist albums, or advance
"properties": {
"release_year": {
"type": "integer",
"minimum": 1870,
"minimum": 1901,
"maximum": 9999
},
"decade": {
@@ -406,16 +412,6 @@ Browse supported lists, related movies, actor credits, artist albums, or advance
"minItems": 1,
"maxItems": 100,
"uniqueItems": true
},
"company_ids": {
"type": "array",
"items": {
"type": "integer",
"minimum": 1
},
"minItems": 1,
"maxItems": 100,
"uniqueItems": true
}
},
"required": [],
@@ -991,7 +987,7 @@ Read issue records, provider-grouped summaries, comments or counts.
## read_votes
Read all voteable requests or votes for a movie, TV or album request.
Read all voteable requests or votes for a movie, TV or album request. Some Ombi 4.53.x data sets make the global or movie read routes fail upstream; the tool reports `UPSTREAM_REJECTED` rather than treating unknown votes as an empty page.
```json
{
@@ -1498,18 +1494,6 @@ Read server status, feature availability, version, update information or usage s
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"const": "news"
}
},
"required": [
"action"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
@@ -1550,7 +1534,9 @@ Read server status, feature availability, version, update information or usage s
}
},
"required": [
"action"
"action",
"from",
"to"
],
"additionalProperties": false
},
@@ -1562,6 +1548,7 @@ Read server status, feature availability, version, update information or usage s
},
"expression": {
"type": "string",
"description": "Quartz cron expression — 6 or 7 fields (seconds minutes hours day-of-month month day-of-week [year]); one of the two day fields must be ?",
"minLength": 1,
"maxLength": 200
}
+3 -3
View File
@@ -16,14 +16,14 @@ Each per-tool output schema is intentionally a bounded projection, not the recur
| Family | Projection rules |
|---|---|
| media_page | Search/discovery/details return zero or more normalized media records. Details usually has one item. Keep every known ID namespace. On TV the upstream `theMovieDbId` field name lies: TVMaze-backed v1 routes (`Search/tv/{term}`, `Search/tv/info/{tvdbId}`) carry the TVDB id there (emit `tvdb`, plus `seriesId`→`tvmaze`); TMDB-keyed v2 routes carry the TMDB id (emit `tmdb`). The namespace label is per origin route, never per field name. Map cast/crew into credits; title-specific streaming into providers; rating fields into named rating references. Never claim a global provider catalogue is a title's availability. Collections keep their own collection identity and returned members. |
| request_page | Map source entity `id` to target kind and ID. v2 TV items are children and `parentRequestId` is preserved; v1 parent records stay parents. Include standard and 4K state separately. Never infer one combined lifecycle status when booleans disagree. |
| media_page | Search/discovery/details return zero or more normalized media records. Details usually has one item. Keep every known ID namespace; never emit the same namespace+value twice. On TV the upstream `theMovieDbId` field name lies: TVMaze-backed v1 routes (`Search/tv/{term}`, `Search/tv/info/{tvdbId}`) carry the TVDB id there (emit `tvdb`, plus `seriesId`→`tvmaze`); TMDB-keyed v2 routes carry the TMDB id (emit `tmdb`) and must not label `seriesId` as `tvmaze` — on those routes it echoes the TMDB id. When `theMovieDbId` is absent, `id` is labelled with the same origin namespace (v2 browse/collection members and MovieFullInfoViewModel). `belongsToCollection.id` is the collection's TMDB id, not the movie's. Multi-search `mediaType` is matched case-insensitively (`Artist`→`artist`/`musicbrainz`). The namespace label is per origin route, never per field name. Credit calls require the caller-supplied person name because Ombi returns only the person ID; TV credit titles are enriched from their TMDB detail records. If requested browse falls back to Ombi's bounded recently-requested feed, mark it truncated and leave total/continuation unknown. Map cast/crew into credits; title-specific streaming into providers; rating fields into named rating references. Never claim a global provider catalogue is a title's availability. Collections keep their own collection identity and returned members. |
| request_page | Map the Ombi request id to target kind and ID: prefer `requestId` over `id`. v2 TV list items are children and `parentRequestId` is preserved; v1 parent records stay parents; child provider ids live on the embedded `parentRequest` record. On `recent`, `RecentlyRequestedModel.requestId` is the request id for movie/album, but on TV rows it is a *child* request id (upstream builds recent TV rows from child requests and persists the provider id as the child PK for new-request children). The `tv_parent` target id is therefore resolved through a bounded v1 parent scan — child-id match first, then `tvDbId`/`externalProviderId` fallback — the child id is emitted as an `ombi_tv_child` identifier, provider values land in `identifiers` (`mediaId`, `tvDbId`, `externalProviderId`), and unresolvable rows emit `target.id` 0 with a warning. A provider-shaped value is never the target. Include standard and 4K state separately. Never infer one combined lifecycle status when booleans disagree. |
| issue_page | Project writable/display fields plus IDs/timestamps. Wire `resovledDate` maps to `resolved_date` without changing upstream spelling. Omit nested user objects and comments unless requested separately. |
| group_page | v2 issue summaries are provider groups. Count and page units describe groups, not individual issues. Truncate nested issues with a warning. |
| comment_page | Preserve comment text and authorized author identifier, omit full user graph. |
| vote_page | Preserve numeric VoteType until verified; totals only if supplied or completely computed from an authorized complete set. No inference of the caller's own vote without identity evidence. |
| user_page | Only ID, username/alias and non-secret language/country/online state. Permission-limited user visibility applies even to nested source objects. |
| reference_page | Named ID/value records for genres, language/country lists, categories, claims, integration options, features and safe metadata. Names/IDs need verified upstream adapters. Unspecified response shapes fail explicitly instead of dumping raw data. |
| reference_page | Named ID/value records for genres, language/country lists, categories, claims, integration options, features and safe metadata. Names/IDs need verified upstream adapters. `value` holds the native-typed identifier (e.g. integer or string) matching the resolved ID, falling back to name string when no ID matched; selection is deterministic across calls. Upstream corrupted labels (all-`?` mojibake or U+FFFD) are skipped for clean candidate keys; if only corrupted labels exist, the corrupted string is emitted and a `warnings[]` note is recorded. Unspecified response shapes fail explicitly instead of dumping raw data. |
| metrics | Counts/quota/health/stats become named scalar values with an explicit instance/principal/selected-user scope. RequestQuota fields are has_limit, limit, remaining and next_request. Server request counts are pending, approved, available and denied. Do not equate false `hasLimit` with remaining=0. |
| settings | Only allowlisted non-secret fields; nested settings are flattened using escaped JSON Pointer names, one scalar leaf per entry. Array indices are display paths only, never mutation identifiers. `revision` appears only when a safe corresponding patch can be offered. No credentials or destination URLs. |
| calendar_page | Known date/title/episode/ID values, no invented time zone or promised date-range filtering. |
+10 -10
View File
@@ -117,7 +117,7 @@ The body/response columns describe the **upstream** schema, not a promise to pas
| 099 | [POST `/api/v1/Plex/Libraries`](../api/raml/api.raml#L1477) | A | `read_integration / plex_libraries` | body [PlexServers](../api/raml/types/Ombi.Core.Settings.Models.External.PlexServers.raml) | 200: [PlexLibrariesResponse](../api/raml/types/Ombi.Models.External.PlexLibrariesResponse.raml) | Private saved-server POST alternative. |
| 100 | [GET `/api/v1/Plex/Libraries/{machineId}`](../api/raml/api.raml#L1497) | D | `read_integration / plex_libraries` | path `machineId`:string required | 200: [PlexLibrariesLiteResponse](../api/raml/types/Ombi.Models.External.PlexLibrariesLiteResponse.raml) | — |
| 101 | [POST `/api/v1/Plex/user`](../api/raml/api.raml#L1510) | X | `Plex account provisioning` | body [PlexUserViewModel](../api/raml/types/Ombi.Models.External.PlexUserViewModel.raml) | 200: body unspecified | Side effects are insufficiently specified; no guessed read lookup. |
| 102 | [GET `/api/v1/Plex/servers`](../api/raml/api.raml#L1525) | D | `read_integration / plex:servers` | none documented | 200: body unspecified | — |
| 102 | [GET `/api/v1/Plex/servers`](../api/raml/api.raml#L1525) | D | `read_integration / plex:servers` | none documented | 200: object wrapper `{"success":bool,"servers":[...]}` | Verified upstream wrapper shape; projects servers array to reference_page with `id=machineId`, `name=serverName`, `value=serverId`. |
| 103 | [POST `/api/v1/Plex/servers`](../api/raml/api.raml#L1525) | I | `Plex authentication/provisioning` | body [UserRequest](../api/raml/types/Ombi.Api.External.MediaServers.Plex.Models.UserRequest.raml) | 200: [PlexServersViewModel](../api/raml/types/Ombi.Models.External.PlexServersViewModel.raml) | Credential-bearing account/OAuth workflow stays outside model tools. |
| 104 | [GET `/api/v1/Plex/friends`](../api/raml/api.raml#L1552) | D | `read_integration / plex:friends` | none documented | 200: array&lt;[UsersViewModel](../api/raml/types/Ombi.Models.External.UsersViewModel.raml)&gt; | — |
| 105 | [POST `/api/v1/Plex/oauth`](../api/raml/api.raml#L1564) | I | `Plex authentication/provisioning` | body [PlexOAuthViewModel](../api/raml/types/Ombi.Models.PlexOAuthViewModel.raml) | 200: body unspecified | Credential-bearing account/OAuth workflow stays outside model tools. |
@@ -234,8 +234,8 @@ The body/response columns describe the **upstream** schema, not a promise to pas
| 216 | [GET `/api/v2/Search/artist/request/{requestId}`](../api/raml/api.raml#L3949) | D | `read_media / by_request` | path `requestId`:integer required | 200: [ArtistInformation](../api/raml/types/Ombi.Core.Models.Search.V2.Music.ArtistInformation.raml) | Request-ID namespace requires verification. MCP `media=album` maps to this literal `artist` route segment. |
| 217 | [GET `/api/v2/Search/artist/album/{albumId}`](../api/raml/api.raml#L3976) | D | `read_media / details` | path `albumId`:string required | 200: [ReleaseGroup](../api/raml/types/Ombi.Core.Models.Search.V2.Music.ReleaseGroup.raml) | — |
| 218 | [GET `/api/v2/Search/releasegroupart/{musicBrainzId}`](../api/raml/api.raml#L4003) | D | `read_library / album_art` | path `musicBrainzId`:string required | 200: [AlbumArt](../api/raml/types/Ombi.Core.Models.Search.V2.Music.AlbumArt.raml) | — |
| 219 | [GET `/api/v2/Search/ratings/movie/{name}/{year}`](../api/raml/api.raml#L4030) | D | `read_media / ratings` | path `name`:string required; path `year`:integer required | 200: [MovieRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.MovieRatings.raml) | — |
| 220 | [GET `/api/v2/Search/ratings/tv/{name}/{year}`](../api/raml/api.raml#L4060) | D | `read_media / ratings` | path `name`:string required; path `year`:integer required | 200: [TvRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.TvRatings.raml) | — |
| 219 | [GET `/api/v2/Search/ratings/movie/{name}/{year}`](../api/raml/api.raml#L4030) | D | `read_media / ratings` | path `name`:string required; path `year`:integer required | 200: [MovieRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.MovieRatings.raml) | Ombi 4.53.x depends on removed Rotten Tomatoes private endpoints; on 404/5xx/empty results, fall back to an exact title/year v1 movie search and label TMDB rating fields. |
| 220 | [GET `/api/v2/Search/ratings/tv/{name}/{year}`](../api/raml/api.raml#L4060) | D | `read_media / ratings` | path `name`:string required; path `year`:integer required | 200: [TvRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.TvRatings.raml) | Ombi 4.53.x depends on removed Rotten Tomatoes private endpoints; on 404/5xx/empty results, fall back to an exact title/year v1 TV search and label TVMaze rating fields. |
| 221 | [GET `/api/v2/Search/stream/movie/{movieDbId}`](../api/raml/api.raml#L4090) | D | `read_media / streaming` | path `movieDBId`:integer required | 200: array&lt;[StreamingData](../api/raml/types/Ombi.Core.Models.Search.V2.StreamingData.raml)&gt; | — |
| 222 | [GET `/api/v2/Search/stream/tv/{movieDbId}`](../api/raml/api.raml#L4120) | D | `read_media / streaming` | path `movieDbId`:integer required | 200: array&lt;[StreamingData](../api/raml/types/Ombi.Core.Models.Search.V2.StreamingData.raml)&gt; | — |
| 223 | [GET `/api/v1/Search/movie/{searchTerm}`](../api/raml/api.raml#L4150) | D | `read_search / text` | path `searchTerm`:unspecified required | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | — |
@@ -298,7 +298,7 @@ The body/response columns describe the **upstream** schema, not a promise to pas
| 280 | [GET `/api/v1/Settings/SickRage`](../api/raml/api.raml#L5100) | D | `read_settings / sickrage` | none documented | 200: [SickRageSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SickRageSettings.raml) | Allowlisted projection; no raw secrets. |
| 281 | [GET `/api/v1/Settings/jobs`](../api/raml/api.raml#L5130) | D | `read_settings / jobs` | none documented | 200: [JobSettings](../api/raml/types/Ombi.Settings.Settings.Models.JobSettings.raml) | Allowlisted projection; no raw secrets. |
| 282 | [POST `/api/v1/Settings/jobs`](../api/raml/api.raml#L5130) | P | `write_settings_patch / patch:jobs` | body [JobSettings](../api/raml/types/Ombi.Settings.Settings.Models.JobSettings.raml) | 200: [JobSettingsViewModel](../api/raml/types/Ombi.Models.JobSettingsViewModel.raml) | Only typed non-secret patch; preserve omitted/private fields. |
| 283 | [POST `/api/v1/Settings/testcron`](../api/raml/api.raml#L5160) | D | `read_server / cron_validate` | body [CronViewModelBody](../api/raml/types/Ombi.Models.CronViewModelBody.raml) | 200: [CronTestModel](../api/raml/types/Ombi.Models.CronTestModel.raml) | Administrator-gated read calculation. |
| 283 | [POST `/api/v1/Settings/testcron`](../api/raml/api.raml#L5160) | D | `read_server / cron_validate` | body [CronViewModelBody](../api/raml/types/Ombi.Models.CronViewModelBody.raml) | 200: [CronTestModel](../api/raml/types/Ombi.Models.CronTestModel.raml) | Administrator-gated read calculation; Quartz uses 6-7 fields and `?` for the unused day field. |
| 284 | [POST `/api/v1/Settings/Issues`](../api/raml/api.raml#L5178) | P | `write_settings_patch / patch:issues` | body [IssueSettings](../api/raml/types/Ombi.Settings.Settings.Models.IssueSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
| 285 | [GET `/api/v1/Settings/Issues`](../api/raml/api.raml#L5178) | D | `read_settings / issues` | none documented | 200: [IssueSettings](../api/raml/types/Ombi.Settings.Settings.Models.IssueSettings.raml) | Allowlisted projection; no raw secrets. |
| 286 | [GET `/api/v1/Settings/issuesenabled`](../api/raml/api.raml#L5208) | D | `read_settings / issuesenabled` | none documented | 200: boolean | Allowlisted projection; no raw secrets. |
@@ -344,12 +344,12 @@ The body/response columns describe the **upstream** schema, not a promise to pas
| 326 | [GET `/api/v1/Sonarr/tags`](../api/raml/api.raml#L5783) | D | `read_integration / options:sonarr/tags` | none documented | 200: array&lt;[Tag](../api/raml/types/Ombi.Api.External.ExternalApis.Sonarr.Models.Tag.raml)&gt; | — |
| 327 | [GET `/api/v1/Sonarr/enabled`](../api/raml/api.raml#L5815) | D | `read_integration / options:sonarr/enabled` | none documented | 200: boolean | — |
| 328 | [GET `/api/v1/Sonarr/version`](../api/raml/api.raml#L5824) | D | `read_integration / options:sonarr/version` | none documented | 200: string | — |
| 329 | [GET `/api/v1/Stats`](../api/raml/api.raml#L5833) | D | `read_server / stats` | query `from`:string optional; query `to`:string optional | 200: [UserStatsSummary](../api/raml/types/Ombi.Core.Engine.UserStatsSummary.raml) | from/to query parameters are supported. |
| 329 | [GET `/api/v1/Stats`](../api/raml/api.raml#L5833) | D | `read_server / stats` | query `from`:string required; query `to`:string required | 200: [UserStatsSummary](../api/raml/types/Ombi.Core.Engine.UserStatsSummary.raml) | from/to are required at the MCP layer; bare calls cause an upstream NRE. |
| 330 | [GET `/api/v1/Status`](../api/raml/api.raml#L5849) | D | `read_server / status` | none documented | 200: [HttpStatusCode](../api/raml/types/System.Net.HttpStatusCode.raml) | — |
| 331 | [GET `/api/v1/Status/info`](../api/raml/api.raml#L5860) | D | `read_server / status_info` | none documented | 200: string | — |
| 332 | [GET `/api/v2/System/news`](../api/raml/api.raml#L5871) | D | `read_server / news` | none documented | 200: body unspecified | — |
| 333 | [GET `/api/v2/System/logs`](../api/raml/api.raml#L5877) | D | `read_logs / list` | none documented | 200: body unspecified | — |
| 334 | [GET `/api/v2/System/logs/{logFileName}`](../api/raml/api.raml#L5883) | D | `read_logs / read` | path `logFileName`:string required | 200: body unspecified | Vetted opaque ID maps to filename; sanitized bounded local slicing. |
| 332 | [GET `/api/v2/System/news`](../api/raml/api.raml#L5871) | X | action removed | none documented | 200: Markdig HTML text | Not structured data; action removed (#13). |
| 333 | [GET `/api/v2/System/logs`](../api/raml/api.raml#L5877) | D | `read_logs / list` | none documented | 200: string array of file names | Verified on 4.53.10. |
| 334 | [GET `/api/v2/System/logs/{logFileName}`](../api/raml/api.raml#L5883) | D | `read_logs / read` | path `logFileName`:string required | 200: plain text | Vetted opaque ID maps to filename; sanitized bounded local slicing (verified 4.53.10). |
| 335 | [GET `/api/v2/System/logs/download/{logFileName}`](../api/raml/api.raml#L5893) | X | `raw diagnostic download` | path `logFileName`:string required | 200: body unspecified | May expose secrets; sanitized logs tool is the supported alternative. |
| 336 | [POST `/api/v1/Tester/discord`](../api/raml/api.raml#L5903) | P | `write_integration_test / discord` | body [DiscordNotificationSettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.DiscordNotificationSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
| 337 | [POST `/api/v1/Tester/pushbullet`](../api/raml/api.raml#L5923) | P | `write_integration_test / pushbullet` | body [PushbulletSettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.PushbulletSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
@@ -383,13 +383,13 @@ The body/response columns describe the **upstream** schema, not a promise to pas
| 365 | [POST `/api/v1/Token/requirePassword`](../api/raml/api.raml#L6464) | I | `upstream authentication adapter` | body [UserAuthModel](../api/raml/types/Ombi.Models.UserAuthModel.raml) | 200: boolean | Not model-callable; JWT-primary auth adapter (`OMBI_AUTH_MODE=jwt`); login/refresh semantics per `01-authentication.md`; Bearer-on-all-routes remains Verify. |
| 366 | [POST `/api/v1/Token/header_auth`](../api/raml/api.raml#L6482) | I | `upstream authentication adapter` | none documented | 200: body unspecified | Not model-callable; JWT-primary auth adapter (`OMBI_AUTH_MODE=jwt`); login/refresh semantics per `01-authentication.md`; Bearer-on-all-routes remains Verify. |
| 367 | [GET `/api/v1/Update`](../api/raml/api.raml#L6494) | D | `read_server / update_info` | none documented | 200: [UpdateModel](../api/raml/types/Ombi.Core.Processor.UpdateModel.raml) | — |
| 368 | [GET `/api/v1/Vote`](../api/raml/api.raml#L6503) | D | `read_votes / list` | none documented | 200: array&lt;[VoteViewModel](../api/raml/types/Ombi.Core.Models.UI.VoteViewModel.raml)&gt; | — |
| 368 | [GET `/api/v1/Vote`](../api/raml/api.raml#L6503) | D | `read_votes / list` | none documented | 200: array&lt;[VoteViewModel](../api/raml/types/Ombi.Core.Models.UI.VoteViewModel.raml)&gt; | Ombi 4.53.10 can return HTTP 500 for existing instance data; no lossless alternate read route exists, so preserve `UPSTREAM_REJECTED`. |
| 369 | [POST `/api/v1/Vote/up/movie/{requestId}`](../api/raml/api.raml#L6515) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 370 | [POST `/api/v1/Vote/up/tv/{requestId}`](../api/raml/api.raml#L6530) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 371 | [POST `/api/v1/Vote/up/album/{requestId}`](../api/raml/api.raml#L6545) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 372 | [POST `/api/v1/Vote/down/movie/{requestId}`](../api/raml/api.raml#L6560) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 373 | [POST `/api/v1/Vote/down/tv/{requestId}`](../api/raml/api.raml#L6575) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 374 | [POST `/api/v1/Vote/down/album/{requestId}`](../api/raml/api.raml#L6590) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 375 | [GET `/api/v1/Vote/movie/{requestId}`](../api/raml/api.raml#L6605) | D | `read_votes / get` | path `requestId`:integer required | 200: array&lt;[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)&gt; | — |
| 375 | [GET `/api/v1/Vote/movie/{requestId}`](../api/raml/api.raml#L6605) | D | `read_votes / get` | path `requestId`:integer required | 200: array&lt;[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)&gt; | Ombi 4.53.10 can return HTTP 500 for existing instance data; no lossless alternate read route exists, so preserve `UPSTREAM_REJECTED`. |
| 376 | [GET `/api/v1/Vote/music/{requestId}`](../api/raml/api.raml#L6621) | D | `read_votes / get` | path `requestId`:integer required | 200: array&lt;[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)&gt; | — |
| 377 | [GET `/api/v1/Vote/tv/{requestId}`](../api/raml/api.raml#L6637) | D | `read_votes / get` | path `requestId`:integer required | 200: array&lt;[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)&gt; | — |
+32 -1
View File
@@ -26,7 +26,7 @@ The schemas deliberately retain runtime checks for semantics JSON Schema cannot
| API-key principal | Security scheme only names a header | Determine effective user, quotas, on-behalf rights and required permissions; do not infer admin or anonymous identity |
| Enum labels | `RequestType` (T1), `IssueStatus` (T2), `NotificationAgent` (T3), `NotificationType` (T4) label maps **published** (routing rule #4); `VoteType`, `RequestSource`, `RequestLimitType` and legacy `orderType`/`statusType`/`availabilityType` filters remain numeric-only gaps | Per-instance verify published maps for version drift and `closed`→3 (not frontend-confirmed); verify symbolic names for the remaining enums from authoritative controller/enum source; preserve raw `*_code` codes meanwhile |
| TV request identity | v1 request body has tvDbId; v2 has theMovieDbId | Keep provider-specific create branches; never substitute one ID namespace for another |
| TV search/details id provenance | `theMovieDbId` carries a different namespace per origin route: TVMaze-backed v1 (`Search/tv/{term}`, `Search/tv/info/{tvdbId}`) places the TVDB id there and the TVMaze id in `seriesId`; v2 routes are TMDB-keyed. The v2 `Search/tv/{tvdbId}` route is a TMDB alias | Label `theMovieDbId` per origin (`tvdb` on v1 TV, `tmdb` on v2) and emit `seriesId` as `tvmaze`; live-confirm the v1 `tv/info/{tvdbId}` route shape, the `RecentlyAdded` TV id namespace, the multi-search TV `id` namespace, and the `by_request` `externalProviderId` namespace |
| TV search/details id provenance | `theMovieDbId` carries a different namespace per origin route: TVMaze-backed v1 (`Search/tv/{term}`, `Search/tv/info/{tvdbId}`) places the TVDB id there and the TVMaze id in `seriesId`; v2 routes are TMDB-keyed. The v2 `Search/tv/{tvdbId}` route is a TMDB alias. v2 `seriesId` echoes the TMDB id. Browse/collection members often omit `theMovieDbId` and only populate `id`. | Label `theMovieDbId` (and, when absent, `id`) per origin (`tvdb` on v1 TV, `tmdb` on v2). Emit `seriesId` as `tvmaze` only on v1 TVMaze-backed routes. Live-confirm the v1 `tv/info/{tvdbId}` route shape, the `RecentlyAdded` TV id namespace, the multi-search TV `id` namespace, and the `by_request` `externalProviderId` namespace |
| TV result granularity | v2 TV list wraps ChildRequests; v1 wraps TvRequests | Preserve target kind and parent ID; no silent fallback between units |
| TV moderation/options/subscriptions/details | Several models/routes only say `id` or `requestId` | Verify each controller's accepted parent/child namespace, independently per operation |
| On behalf | requestOnBehalf is just string | Confirm ID versus username and permissions; resolve the public user ID internally if necessary |
@@ -298,3 +298,34 @@ The gated live suite is committed and ready; run it with a sourced `.env`. It ad
- Input/output schema contracts in `docs/schema/` unchanged.
- `go test ./...` output is identical to before the phase (package fully behind the tag).
- No instance URL in committed files; `.env` guidance points to the gitignored file only.
## M5 Findings — Request state and list routes (#7, #10)
- `#7`: `GET /api/v1/Search/tv/info/{tvdbId}` (TVMaze-backed info route) upstream does not set request state flags accurately, returning `requested: false` regardless of truth. The adapter patches this by performing a bounded `GET /api/v1/Request/tv` parent scan and overlaying per-episode availability and request states.
- `#10`: `GET /api/v2/Requests/{movie,tv,album}/...` (v2 lists) encounter a per-row `NullReferenceException` on non-empty results (observed on Ombi 4.53.10). The adapter surfaces this as `UPSTREAM_REJECTED` rather than silently replacing it with v1 `tvlite` payloads, preserving the expected v2 child-page contract.
- `#10` search fallback: `GET /api/v1/Request/tv/search/{term}` on 4.53.10 suffers from a LINQ translation bug (upstream Ombi-app/Ombi#5420, fixed by #5421). The adapter gracefully falls back to a bounded v1 parent scan filtering locally on the term if the primary search route fails.
## M6 Findings — Integration options and server-id discovery (#16, #17, #18, #20)
- `#20`: `refOf` reference projections defaulted `value` to the first scalar map entry (`firstScalar`), which produced nondeterministic results depending on Go map iteration order (e.g. leaking logo paths, booleans, or unrelated weights). The contract is now deterministic: `value` holds the native-typed identifier matching the resolved ID key, falling back to name string if no ID matched, or omitted otherwise.
- `#20` mojibake handling: language endpoints on Ombi 4.53.10 occasionally return corrupted strings like `"??????"` or containing `\uFFFD`. `refOf` skips corrupted candidates in favor of clean alternates (e.g. `name` instead of corrupted `english_name`); if all candidates are corrupted, the string is emitted and a degradation note is recorded in `warnings[]`.
- `#17`: Root-folder records (`/api/v1/{Sonarr,Radarr,Lidarr}/RootFolders`) have no `name` property on the wire (only `path`). Category key table `refKeySet` now maps `path` to `name` and preserves `id` in `value` (e.g. `{id: "21", name: "/media/tv", value: 21}`), directly usable as `root_folder_id` in write operations.
- `#16`: `GET /api/v1/Plex/servers` returns an object wrapper `{"success": true, "servers": [...]}` rather than a top-level array. `refsOrUsers` now inspects wrappers, verifies success/failure flags (mapping `success: false` to `UPSTREAM_REJECTED` with sanitized upstream messages), and decodes server entries with `id=machineId`, `name=serverName`, `value=serverId`.
- `#16` sibling decode fixes: `refsOrScalar` was previously intercepting any valid JSON object in its `decodeScalar` branch and emitting a junk `{name: "<cat>"}` record, leaving nested-container extraction dead code. Reordering decode passes (`refArray` → `decodeObject` → `decodeScalar`) fixes `plex_libraries`, `media_server info`, and `media_server libraries`.
- `#18`: Saved-server identity discovery: `flattenSettings` previously excluded `id`, `serverId`, and `machineIdentifier` everywhere, making it impossible to discover server IDs for `read_integration media_server` and `plex_libraries`. A scoped read-exemption allows server identity leaves under `/servers/<digits>/` to appear in `read_settings` values (top-level section IDs and credentials remain omitted; patches to server identity fields remain rejected with `INVALID_ARGUMENT`).
### Residual Verify items
- Verify `PlexServersAddUserModel` and older Ombi instances where `servers` may be returned as a bare array (now tolerated alongside object wrappers).
- Verify Emby `selectedLibraries[].key` ↔ MediaFolders `id` correspondence across diverse Emby/Jellyfin setups.
## M7 Findings — Admin/server wire contracts (#19, #13, #14, #15)
- `#19`: `GET /api/v2/System/logs` returns a JSON string array of log file names on Ombi 4.53.10. The adapter accepts that form and the legacy object form, then reads the selected file as plain text.
- `#13`: `GET /api/v2/System/news` returns Markdig-rendered HTML text rather than structured JSON. The `news` action was removed from `read_server`.
- `#14`: `GET /api/v1/Stats` binds non-nullable `from` and `to` DateTimes; an empty range triggers an upstream null-reference error. The adapter requires both RFC3339 values before calling Ombi.
- `#15`: Ombi validates Quartz.NET cron expressions. They have six or seven fields and require `?` in one of day-of-month or day-of-week; for example, `0 0 0 * * ?` validates while five-field cron and expressions with both day fields as `*` do not.
## M8 Findings — Upstream ratings and vote failures (#8, #12)
- `#8`: Ombi 4.53.10 implements both v2 ratings routes by calling `www.rottentomatoes.com/api/private`; both private endpoints now return HTTP 404 and Ombi surfaces the dependency failure as HTTP 500. `read_media ratings` retains the native result when available and otherwise performs an exact title/year lookup through Ombi's normal movie or TV search. Fallback values are source-labelled (`tmdb_vote_average`, `tmdb_vote_count`, or `tvmaze_site_rating`) and the result carries a degradation warning. No fuzzy title or year substitution is allowed.
- `#12`: `GET /api/v1/Vote` and `GET /api/v1/Vote/movie/{requestId}` return HTTP 500 on the verified Ombi 4.53.10 data set, while an empty TV request returns HTTP 200 with `[]`. The global controller builds derived per-request summaries and the per-media controllers are the only raw vote-record reads; there is no second lossless API from which the MCP can recover user vote identity and counts. The adapter therefore preserves the sanitized, retryable `UPSTREAM_REJECTED` error and never substitutes an empty page. Mock coverage fixes this error boundary as part of the public contract.
+1 -1
View File
@@ -59,7 +59,7 @@ These types deliberately exclude credentials, connection destinations, internal
## Fields excluded from patches
The following exact field names are excluded recursively wherever encountered; `key` in a selected-library record is an identifier, not automatically a secret. Read projections also exclude private values and use an allowlist, not merely this name list.
The following exact field names are excluded recursively wherever encountered; `key` in a selected-library record is an identifier, not automatically a secret. Read projections also exclude private values and use an allowlist, not merely this name list. In read projections (`read_settings`), a scoped exemption allows server identity fields (`id`, `serverId`, `machineIdentifier`) to be projected when they are direct leaves of a server record under `/servers/<digits>` (e.g. `/servers/0/id`, `/servers/0/serverId`, `/servers/0/machineIdentifier`), making saved servers discoverable for `read_integration media_server` and `plex_libraries`. In patches (`write_settings_patch`), these fields remain strictly excluded and unpatchable.
`accessToken`, `accountSid`, `administratorId`, `apiKey`, `applicationToken`, `applicationUrl`, `authToken`, `authorizationHeader`, `baseUrl`, `botApi`, `customDonationUrl`, `disableCertificateChecking`, `disableTLS`, `favicon`, `hasMigratedOldTvDbData`, `host`, `iconUrl`, `id`, `installId`, `ip`, `logo`, `machineIdentifier`, `password`, `plexAuthToken`, `port`, `processName`, `scriptLocation`, `serverHostname`, `serverId`, `set`, `ssl`, `subDir`, `useScript`, `userToken`, `webhookUrl`, `windowsService`, `windowsServiceName`, `wizard`.
File diff suppressed because it is too large Load Diff
+277
View File
@@ -4,6 +4,7 @@ package integration_test
import (
"encoding/json"
"fmt"
"os"
"strings"
"testing"
@@ -70,6 +71,50 @@ func TestLiveServerStatus(t *testing.T) {
assertNoLeak(t, out.Raw)
}
func TestLiveServerStats(t *testing.T) {
c := liveServer(t)
out := c.callTool(t, "read_server", map[string]any{
"action": "stats", "from": "2026-09-01T00:00:00Z", "to": "2026-09-02T00:00:00Z",
})
data := requireOK(t, out)
var metrics struct {
Kind string `json:"kind"`
}
if err := json.Unmarshal(data, &metrics); err != nil || metrics.Kind != "metrics" {
t.Fatalf("stats result: %v: %s", err, data)
}
}
func TestLiveLogs(t *testing.T) {
c := liveServer(t)
out := c.callTool(t, "read_logs", map[string]any{"action": "list"})
data := requireOK(t, out)
var logs struct {
Kind string `json:"kind"`
}
if err := json.Unmarshal(data, &logs); err != nil || logs.Kind != "logs" {
t.Fatalf("logs result: %v: %s", err, data)
}
}
func TestLiveCronValidateQuartz(t *testing.T) {
c := liveServer(t)
out := c.callTool(t, "read_server", map[string]any{
"action": "cron_validate", "expression": "0 0 0 * * ?",
})
data := requireOK(t, out)
if !strings.Contains(string(data), `"name":"valid","value":true`) {
t.Fatalf("Quartz-valid expression reported invalid: %s", data)
}
out = c.callTool(t, "read_server", map[string]any{
"action": "cron_validate", "expression": "0 0 * * *",
})
data = requireOK(t, out)
if !strings.Contains(string(data), `"name":"valid","value":false`) {
t.Fatalf("five-field cron reported valid: %s", data)
}
}
// --- read/projection contract against real payloads ---
func TestLiveReadRequestsList(t *testing.T) {
@@ -443,3 +488,235 @@ func TestLiveNotFound(t *testing.T) {
t.Errorf("http_status = %v", e.HTTPStatus)
}
}
// --- M3 identity projection live ---
func TestLiveDiscoverTVBrowseHasIdentifiers(t *testing.T) {
c := liveServer(t)
out := c.callTool(t, "read_discover", map[string]any{
"action": "browse", "media": "tv", "category": "popular",
"page": map[string]any{"limit": 5},
})
data := requireOK(t, out)
var page struct {
Items []struct {
Title string `json:"title"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
if len(page.Items) == 0 {
t.Skip("live TV popular browse returned no items")
}
for _, it := range page.Items {
if len(it.Identifiers) == 0 {
t.Fatalf("browse item %q has empty identifiers (issue #2)", it.Title)
}
}
assertNoLeak(t, out.Raw)
}
func TestLiveRecentTVParentTargetIsCallable(t *testing.T) {
c := liveServer(t)
out := c.callTool(t, "read_requests", map[string]any{"action": "recent"})
data := requireOK(t, out)
var page struct {
Items []struct {
Target struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
Title string `json:"title"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
var tv *struct {
Target struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
Title string `json:"title"`
}
for i := range page.Items {
if page.Items[i].Target.Kind == "tv_parent" {
tv = &page.Items[i]
break
}
}
if tv == nil {
t.Skip("live recent feed has no TV rows")
}
// Issue #11: upstream emits a provider-shaped child request id
// here; the resolved tv_parent id must be callable via get.
if tv.Target.ID < 1 {
t.Skipf("recent tv row %q left unresolved (id 0)", tv.Title)
}
out = c.callTool(t, "read_requests", map[string]any{
"action": "get",
"target": map[string]any{"kind": "tv_parent", "id": tv.Target.ID},
})
if out.IsError {
t.Fatalf("recent tv_parent target %d not callable via get (issue #11): %s",
tv.Target.ID, out.Raw)
}
assertNoLeak(t, out.Raw)
}
func TestLiveSearchMultiArtistMapped(t *testing.T) {
c := liveServer(t)
out := c.callTool(t, "read_search", map[string]any{
"action": "multi", "query": "radiohead", "include": []string{"music"},
})
data := requireOK(t, out)
var page struct {
Items []struct {
Media string `json:"media"`
Title string `json:"title"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
if len(page.Items) == 0 {
t.Skip("live multi music search returned no items")
}
it := page.Items[0]
if it.Media == "unknown" {
t.Fatalf("music result media=unknown (issue #9): %s", data)
}
for _, id := range it.Identifiers {
if id.Namespace == "provider_unknown" {
t.Fatalf("music result labelled provider_unknown (issue #9): %s", data)
}
}
assertNoLeak(t, out.Raw)
}
func TestLiveReferenceGenresValue(t *testing.T) {
c := liveServer(t)
out := c.callTool(t, "read_reference", map[string]any{
"action": "genres",
"media": "movie",
})
data := requireOK(t, out)
var page struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
if len(page.Items) == 0 {
t.Skip("live genres returned no items")
}
for _, it := range page.Items {
idNum, ok := it.Value.(float64)
if !ok {
t.Fatalf("genre item %q id %q has non-numeric value: %v (%T)", it.Name, it.ID, it.Value, it.Value)
}
if fmt.Sprintf("%.0f", idNum) != it.ID {
t.Fatalf("genre item %q id %q does not match value %.0f", it.Name, it.ID, idNum)
}
}
assertNoLeak(t, out.Raw)
}
func TestLivePlexServersAndLibraries(t *testing.T) {
c := liveServer(t)
out := c.callTool(t, "read_integration", map[string]any{
"action": "plex",
"resource": "servers",
})
if out.IsError {
t.Skip("live plex servers returned error")
}
data := requireOK(t, out)
var page struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
if len(page.Items) == 0 {
t.Skip("no plex servers configured")
}
server := page.Items[0]
if server.ID == "" {
t.Fatalf("plex server ID (machineId) is empty: %+v", server)
}
outLibs := c.callTool(t, "read_integration", map[string]any{
"action": "plex_libraries",
"machine_id": server.ID,
})
requireOK(t, outLibs)
assertNoLeak(t, outLibs.Raw)
}
func TestLiveServerIdentityDiscoveryAndMediaServer(t *testing.T) {
c := liveServer(t)
var foundServerID string
var foundService string
for _, svc := range []string{"emby", "jellyfin", "plex"} {
out := c.callTool(t, "read_settings", map[string]any{
"section": svc,
})
if out.IsError {
continue
}
data := requireOK(t, out)
var s struct {
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
OmittedFields []string `json:"omitted_fields"`
}
if err := json.Unmarshal(data, &s); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
for _, v := range s.Values {
if strings.HasPrefix(v.Name, "/servers/") {
if strings.HasSuffix(v.Name, "/id") || strings.HasSuffix(v.Name, "/serverId") || strings.HasSuffix(v.Name, "/machineIdentifier") {
if foundServerID == "" && (svc == "emby" || svc == "jellyfin") {
foundServerID = fmt.Sprintf("%v", v.Value)
foundService = svc
}
}
}
}
for _, o := range s.OmittedFields {
if strings.HasPrefix(o, "/servers/") && (strings.HasSuffix(o, "/id") || strings.HasSuffix(o, "/serverId") || strings.HasSuffix(o, "/machineIdentifier")) {
t.Errorf("server identity field %q found in omitted_fields for %s", o, svc)
}
}
}
if foundServerID != "" && foundService != "" {
outInfo := c.callTool(t, "read_integration", map[string]any{
"action": "media_server",
"service": foundService,
"resource": "info",
"server_id": foundServerID,
})
if !outInfo.IsError {
assertNoLeak(t, outInfo.Raw)
}
}
}
+327 -6
View File
@@ -53,19 +53,41 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
m.settings = map[string]map[string]any{
"/api/v1/Settings/customization": mockCustomization(),
"/api/v1/Settings/notifications/discord": mockDiscordSettings(),
"/api/v1/Settings/emby": mockMediaServerSettings("Emby One", "emby-guid", 3),
"/api/v1/Settings/jellyfin": mockMediaServerSettings("Jellyfin One", "jf-guid", 4),
"/api/v1/Settings/plex": mockMediaServerSettings("Plex One", "plex-guid", 5),
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/Token", m.handleToken)
mux.HandleFunc("GET /api/v1/Status", m.wrap(m.fixed(`200`)))
mux.HandleFunc("GET /api/v1/Status/info", m.wrap(m.fixed(`"mock-status-info"`)))
mux.HandleFunc("GET /api/v1/Settings/about", m.wrap(m.json(mockAbout())))
mux.HandleFunc("GET /api/v1/Stats", m.wrap(m.stats))
mux.HandleFunc("POST /api/v1/Settings/testcron", m.wrap(m.testcron))
mux.HandleFunc("GET /api/v2/System/logs", m.wrap(m.fixed(`["ombi-20260918.txt","ombi-20260919.txt"]`)))
mux.HandleFunc("GET /api/v2/System/logs/{logFileName}", m.wrap(m.logsRead))
mux.HandleFunc("GET /api/v1/Request/tv/{count}/{pos}/{o}/{s}/{a}", m.wrap(m.tvParentList))
mux.HandleFunc("GET /api/v1/Request/tv/{id}/child", m.wrap(m.tvChildren))
mux.HandleFunc("GET /api/v2/Requests/movie/{amt}/{pos}/requestDate/{order}", m.wrap(m.movieList))
mux.HandleFunc("GET /api/v2/Requests/tv/{amt}/{pos}/requestDate/{order}", m.wrap(m.tvList))
mux.HandleFunc("GET /api/v2/Search/movie/{id}", m.wrap(m.movieDetails))
mux.HandleFunc("GET /api/v2/Search/movie/collection/{id}", m.wrap(m.movieCollection))
mux.HandleFunc("GET /api/v2/Search/movie/requested/{pos}/{amt}", m.wrap(m.emptyBrowse))
mux.HandleFunc("GET /api/v2/Search/tv/moviedb/{id}", m.wrap(m.tvDetailsTMDB))
mux.HandleFunc("GET /api/v2/Search/tv/{id}", m.wrap(m.tvDetailsTVDB))
mux.HandleFunc("GET /api/v2/Search/tv/popular/{pos}/{amt}", m.wrap(m.tvBrowse))
mux.HandleFunc("GET /api/v2/Search/tv/anticipated/{pos}/{amt}", m.wrap(m.tvBrowse))
mux.HandleFunc("GET /api/v2/Search/tv/trending/{pos}/{amt}", m.wrap(m.tvBrowse))
mux.HandleFunc("GET /api/v2/Search/tv/requested/{pos}/{amt}", m.wrap(m.emptyBrowse))
mux.HandleFunc("GET /api/v2/Search/actor/{id}/movie", m.wrap(m.actorMovieCredits))
mux.HandleFunc("GET /api/v2/Search/actor/{id}/tv", m.wrap(m.actorTVCredits))
mux.HandleFunc("POST /api/v2/Search/advancedSearch/movie/{pos}/{amt}", m.wrap(m.advancedMovie))
mux.HandleFunc("POST /api/v2/Search/multi/{term}", m.wrap(m.multiSearch))
mux.HandleFunc("GET /api/v2/Search/ratings/{media}/{name}/{year}", m.wrap(m.ratingsUnavailable))
mux.HandleFunc("GET /api/v2/Requests/recentlyRequested", m.wrap(m.recentlyRequested))
mux.HandleFunc("GET /api/v1/Search/tv/info/{id}", m.wrap(m.tvInfoTVDB))
mux.HandleFunc("GET /api/v1/Search/tv/{term}", m.wrap(m.tvSearch))
mux.HandleFunc("GET /api/v1/Search/movie/{term}", m.wrap(m.movieSearch))
mux.HandleFunc("GET /api/v1/Request/movie/info/{id}", m.wrap(m.movieInfo))
mux.HandleFunc("POST /api/v2/Requests/tv", m.wrap(m.createTV))
mux.HandleFunc("POST /api/v1/Request/tv", m.wrap(m.createTV))
@@ -78,6 +100,15 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
mux.HandleFunc("POST /api/v1/Settings/customization", m.wrap(m.settingsPost))
mux.HandleFunc("GET /api/v1/Settings/notifications/discord", m.wrap(m.settingsGet))
mux.HandleFunc("POST /api/v1/Settings/notifications/discord", m.wrap(m.settingsPost))
mux.HandleFunc("GET /api/v1/Settings/emby", m.wrap(m.settingsGet))
mux.HandleFunc("POST /api/v1/Settings/emby", m.wrap(m.settingsPost))
mux.HandleFunc("GET /api/v1/Settings/jellyfin", m.wrap(m.settingsGet))
mux.HandleFunc("POST /api/v1/Settings/jellyfin", m.wrap(m.settingsPost))
mux.HandleFunc("GET /api/v1/Settings/plex", m.wrap(m.settingsGet))
mux.HandleFunc("POST /api/v1/Settings/plex", m.wrap(m.settingsPost))
mux.HandleFunc("GET /api/v1/Emby/users", m.wrap(m.fixed(`[{"id":"e-1","username":"emb"}]`)))
mux.HandleFunc("POST /api/v1/Emby/info", m.wrap(m.fixed(`{"id":"emby-guid","serverName":"Emby One","version":"4.8.0"}`)))
mux.HandleFunc("POST /api/v1/Emby/Library", m.wrap(m.fixed(`{"items":[{"name":"Movies","serverId":"emby-guid","id":"f137","collectionType":"movies"}]}`)))
mux.HandleFunc("GET /api/v1/Request/movie/search/{q}", m.wrap(m.malformed))
mux.HandleFunc("GET /api/v1/Request/count", m.wrap(m.fixed(`{"pending":3,"approved":2,"available":5,"denied":1}`)))
mux.HandleFunc("GET /api/v1/Request/movie/total", m.wrap(m.fixed(`7`)))
@@ -86,6 +117,17 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
mockUser("u-1", "alice"), mockUser("u-2", "bob"),
})))
mux.HandleFunc("GET /api/v1/Identity/User/{id}", m.wrap(m.userGet))
mux.HandleFunc("GET /api/v1/Vote", m.wrap(m.voteListFailure))
mux.HandleFunc("GET /api/v1/Vote/{media}/{id}", m.wrap(m.voteGet))
mux.HandleFunc("GET /api/v1/Radarr/Profiles", m.wrap(m.fixed(`[{"id":6,"name":"HD","weight":7,"enabled":false}]`)))
mux.HandleFunc("GET /api/v2/Search/Genres/{media}", m.wrap(m.fixed(`[{"id":53,"name":"Thriller"}]`)))
mux.HandleFunc("GET /api/v2/Search/Languages", m.wrap(m.fixed(`[{"iso_639_1":"ky","english_name":"??????","name":"Кыргызча"},{"iso_639_1":"zz","english_name":"??????","name":"??????"}]`)))
mux.HandleFunc("GET /api/v1/TheMovieDb/WatchProviders/{media}", m.wrap(m.fixed(`[{"provider_id":8,"provider_name":"Netflix","logo_path":"/x.png"}]`)))
mux.HandleFunc("GET /api/v1/Sonarr/RootFolders", m.wrap(m.fixed(`[{"id":21,"path":"/media/tv","freespace":9e9}]`)))
mux.HandleFunc("GET /api/v1/Plex/servers", m.wrap(m.fixed(`{"success":true,"servers":[{"serverId":7,"machineId":"mach-plex-1","serverName":"Main Plex"}]}`)))
mux.HandleFunc("GET /api/v1/Plex/Libraries/{machineId}", m.wrap(m.plexLibraries))
mux.HandleFunc("GET /api/v1/Plex/friends", m.wrap(m.fixed(`[{"id":"pf-1","username":"plexfriend"}]`)))
mux.HandleFunc("GET /api/v1/Plex/WatchlistUsers", m.wrap(m.fixed(`[{"userId":"w-1","userName":"wl","syncStatus":1}]`)))
mux.HandleFunc("/api/v1/", m.wrap(m.catchAll)) // fallthrough: 404
m.Server = httptest.NewServer(mux)
t.Cleanup(m.Server.Close)
@@ -286,7 +328,9 @@ func mockUser(id, name string) map[string]any {
// mockMovieDetail: SearchMovieViewModel-ish with extra fields.
func mockMovieDetail() map[string]any {
return map[string]any{
"id": 27205, "theMovieDbId": 27205, "imdbId": "tt1375666",
// Live MovieFullInfoViewModel carries TMDB in `id` and often
// omits theMovieDbId — the projection must fall back to id.
"id": 27205, "imdbId": "tt1375666",
"title": "Inception", "originalTitle": "Inception",
"overview": "A thief who steals corporate secrets.",
"releaseDate": "2010-07-15T00:00:00", "status": "Released",
@@ -305,7 +349,9 @@ func mockMovieDetail() map[string]any {
map[string]any{"id": 525, "name": "Christopher Nolan", "department": "Directing", "job": "Director"},
},
"externalIds": map[string]any{"imdbId": "tt1375666"},
"belongsToCollection": nil,
"belongsToCollection": map[string]any{
"id": 8091, "name": "Alien Collection",
},
// Undocumented upstream fields:
"productionCompanies": []any{map[string]any{"id": 1, "name": "Legendary"}},
"videos": map[string]any{"results": []any{}},
@@ -319,7 +365,10 @@ func mockMovieDetail() map[string]any {
// both the seasons projection and the write expansion path.
func mockTVDetail() map[string]any {
return map[string]any{
// Live v2 moviedb payloads put the TMDB id in seriesId too;
// that must not be labelled tvmaze.
"id": 1396, "theMovieDbId": 1396, "theTvDbId": 81189,
"seriesId": 1396,
"title": "Breaking Bad", "name": "Breaking Bad",
"overview": "A chemistry teacher turns to cooking meth.",
"firstAired": "2008-01-20T00:00:00", "status": "Ended",
@@ -340,7 +389,7 @@ func mockTVDetail() map[string]any {
map[string]any{
"seasonNumber": 1,
"episodes": []any{
map[string]any{"episodeNumber": 1, "title": "Pilot", "available": true, "requested": true},
map[string]any{"episodeNumber": 1, "title": "Pilot", "available": true},
map[string]any{"episodeNumber": 2, "title": "Cat's in the Bag...", "available": true},
map[string]any{"episodeNumber": 3, "title": "...And the Bag's in the River"},
map[string]any{"episodeNumber": 4, "title": "Cancer Man"},
@@ -379,7 +428,8 @@ func mockTVInfoV1() map[string]any {
"title": "Breaking Bad", "name": "Breaking Bad",
"overview": "A chemistry teacher turns to cooking meth.",
"firstAired": "2008-01-20T00:00:00", "status": "Ended",
"available": false, "requested": false, "fullyAvailable": false,
// Live sends requestId 0 (not absent) on unrequested shows.
"available": false, "requested": false, "requestId": 0, "fullyAvailable": false,
"poster": "/ggFHVNu6YYI5L9pCfOacjizRGt.jpg",
"banner": "/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
"genre": []any{"Crime", "Drama", "Thriller"},
@@ -414,7 +464,7 @@ func mockTVSearchHit() map[string]any {
"imdbId": "tt0903747",
"title": "Breaking Bad", "name": "Breaking Bad",
"overview": "A chemistry teacher turns to cooking meth.",
"firstAired": "2008-01-20T00:00:00",
"firstAired": "2008-01-20T00:00:00", "siteRating": 9,
"available": false, "requested": false,
"poster": "/ggFHVNu6YYI5L9pCfOacjizRGt.jpg",
"genre": []any{"Crime", "Drama"},
@@ -422,6 +472,13 @@ func mockTVSearchHit() map[string]any {
}
}
func mockMovieSearchHit() map[string]any {
return map[string]any{
"id": 348, "title": "Alien", "releaseDate": "1979-05-25T00:00:00",
"voteAverage": 8.2, "voteCount": 15400,
}
}
func mockMovieRequest() map[string]any {
return map[string]any{
"id": 10, "requestId": 10, "theMovieDbId": 27205, "imdbId": "tt1375666",
@@ -454,7 +511,12 @@ func mockTVChild() map[string]any {
},
},
},
"tvDbId": 81189, "externalProviderId": 1396, "imdbId": "tt0903747",
// ChildRequests carries no top-level provider ids upstream —
// they live on the embedded parent navigation property.
"parentRequest": map[string]any{
"id": 12, "tvDbId": 81189,
"externalProviderId": 1396, "imdbId": "tt0903747",
},
"childFieldFuture": []any{1, 2},
}
}
@@ -502,6 +564,24 @@ func mockDiscordSettings() map[string]any {
}
}
func mockMediaServerSettings(name, serverGuid string, serverID int) map[string]any {
return map[string]any{
"enable": true,
"id": 9,
"servers": []any{
map[string]any{
"id": serverID,
"serverId": serverGuid,
"name": name,
"apiKey": "SECRET",
"administratorId": "a",
"ip": "10.0.0.5",
"port": 8096,
},
},
}
}
// --- handlers ---
func (m *mockOmbi) movieList(w http.ResponseWriter, r *http.Request) {
@@ -514,6 +594,76 @@ func (m *mockOmbi) movieList(w http.ResponseWriter, r *http.Request) {
})
}
func (m *mockOmbi) tvParentList(w http.ResponseWriter, r *http.Request) {
pos := r.PathValue("pos")
if pos != "0" {
m.json(map[string]any{"collection": []any{}, "total": 1})(w, r)
return
}
// RequestsViewModel<TvRequests>: the v1 paged route wraps rows in
// {collection,total} — scans that decode a bare array break on live.
m.json(map[string]any{
"collection": []any{
map[string]any{
"id": 42,
"tvDbId": 81189,
"externalProviderId": 1396,
"imdbId": "tt0903747",
"title": "trigger-500 and something", // For search fallback test
"childRequests": []any{
map[string]any{
// A real (non-provider-shaped) child request
// id — recent rows reference this in requestId.
"id": 88,
"seasonRequests": []any{
map[string]any{
"childRequestId": 88,
"seasonNumber": 1,
"episodes": []any{
map[string]any{"episodeNumber": 1, "requested": true},
map[string]any{"episodeNumber": 2, "requested": true},
map[string]any{"episodeNumber": 3, "requested": true},
},
},
},
},
},
},
map[string]any{
// Upstream persists the provider id as the child PK
// for new-request children — this child's id is the
// parent's tvDbId, exactly like live rows.
"id": 909,
"tvDbId": 259032,
"externalProviderId": 118680,
"imdbId": "tt1439629",
"title": "Seven Up!",
"childRequests": []any{
map[string]any{
"id": 259032,
"seasonRequests": []any{
map[string]any{
"childRequestId": 259032,
"seasonNumber": 1,
"episodes": []any{},
},
},
},
},
},
},
"total": 2,
})(w, r)
}
func (m *mockOmbi) tvChildren(w http.ResponseWriter, r *http.Request) {
if r.PathValue("id") != "12" {
m.jsonErr(w, http.StatusNotFound, "Parent request not found")
return
}
m.json([]any{mockTVChild()})(w, r)
}
func (m *mockOmbi) tvList(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
@@ -534,6 +684,10 @@ func (m *mockOmbi) movieDetails(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html><body><h1>500 Internal Server Error</h1>" +
"System.NullReferenceException at Ombi.Core.Engine.MovieRequestEngine" +
"Authorization: Bearer should-never-appear</body></html>"))
case "500001":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "Object reference not set to an instance of an object. System.NullReferenceException"}`))
default:
m.jsonErr(w, http.StatusNotFound, "Movie not found")
}
@@ -577,9 +731,135 @@ func (m *mockOmbi) tvInfoTVDB(w http.ResponseWriter, r *http.Request) {
}
func (m *mockOmbi) tvSearch(w http.ResponseWriter, r *http.Request) {
if r.PathValue("term") == "trigger-500" {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("<html><body><h1>500 Internal Server Error</h1>" +
"System.NullReferenceException at Ombi.Core.Engine.TvRequestEngine</body></html>"))
return
}
m.json([]any{mockTVSearchHit()})(w, r)
}
func (m *mockOmbi) movieSearch(w http.ResponseWriter, r *http.Request) {
m.json([]any{mockMovieSearchHit()})(w, r)
}
func (m *mockOmbi) ratingsUnavailable(w http.ResponseWriter, r *http.Request) {
m.jsonErr(w, http.StatusInternalServerError, "external ratings provider failed")
}
func (m *mockOmbi) voteListFailure(w http.ResponseWriter, r *http.Request) {
m.jsonErr(w, http.StatusInternalServerError, "vote view could not be generated")
}
func (m *mockOmbi) voteGet(w http.ResponseWriter, r *http.Request) {
if r.PathValue("media") == "tv" {
m.json([]any{})(w, r)
return
}
m.jsonErr(w, http.StatusInternalServerError, "vote records could not be read")
}
// tvBrowse is the v2 TMDB-keyed popular/anticipated/trending list —
// members carry the provider id only in `id`.
func (m *mockOmbi) tvBrowse(w http.ResponseWriter, r *http.Request) {
m.json([]any{
map[string]any{
"id": 1668, "title": "Reacher",
"overview": "A former military policeman.",
"posterPath": "/reacher.jpg",
},
})(w, r)
}
func (m *mockOmbi) movieCollection(w http.ResponseWriter, r *http.Request) {
m.json(map[string]any{
"id": 8091, "name": "Alien Collection", "overview": "In space…",
"collection": []any{
map[string]any{
"id": 348, "title": "Alien",
"releaseDate": "1979-05-25T00:00:00",
"posterPath": "/alien.jpg",
},
},
})(w, r)
}
func (m *mockOmbi) emptyBrowse(w http.ResponseWriter, r *http.Request) {
m.json([]any{})(w, r)
}
func (m *mockOmbi) actorMovieCredits(w http.ResponseWriter, r *http.Request) {
m.json(map[string]any{
"id": r.PathValue("id"),
"cast": []any{map[string]any{
"id": 27205, "title": "Inception", "overview": "A thief who steals corporate secrets.",
"release_date": "2010-07-15", "poster_path": "/inception.jpg", "character": "Cobb",
}},
"crew": []any{map[string]any{
"id": 27205, "title": "Inception", "job": "Producer", "department": "Production",
}},
})(w, r)
}
func (m *mockOmbi) actorTVCredits(w http.ResponseWriter, r *http.Request) {
// Ombi's ActorCredits DTO discards TMDB's TV name/original_name fields.
m.json(map[string]any{
"id": r.PathValue("id"),
"cast": []any{map[string]any{
"id": 1396, "character": "Walter White", "overview": "A chemistry teacher.",
}},
})(w, r)
}
func (m *mockOmbi) advancedMovie(w http.ResponseWriter, r *http.Request) {
m.json([]any{mockMovieDetail()})(w, r)
}
func (m *mockOmbi) multiSearch(w http.ResponseWriter, r *http.Request) {
m.json([]any{
map[string]any{
"id": "8dc08e7c-41f1-4a04-97dc-eb00d91d1d2f",
"mediaType": "Artist", "title": "Radiohead",
"poster": "/radiohead.jpg",
},
})(w, r)
}
func (m *mockOmbi) recentlyRequested(w http.ResponseWriter, r *http.Request) {
m.json([]any{
map[string]any{
"requestId": 2207, "type": 1, "title": "Yummy",
"mediaId": "12345", "userId": "u-1",
"requestDate": "2026-09-01T10:00:00",
},
map[string]any{
// Live TV rows carry the CHILD request id in requestId —
// for new-request children upstream persists the provider
// id as the child PK — and the parent's externalProviderId
// in mediaId. The parent request id is absent entirely.
"requestId": 259032, "type": 0,
"title": "Seven Up!", "mediaId": "118680",
"userId": "u-1", "requestDate": "2026-09-02T10:00:00",
},
map[string]any{
// A real (non-provider-shaped) child id — resolvable only
// via the parent's embedded childRequests[].id.
"requestId": 88, "type": 0,
"title": "trigger-500 and something", "mediaId": "1396",
"userId": "u-2", "requestDate": "2026-09-03T10:00:00",
},
map[string]any{
// Unresolvable: no requestId, no parent match; a stray
// provider-shaped `id` must never occupy target.id.
"id": 111, "type": 0, "title": "Provider Only",
"mediaId": "111", "userId": "u-1",
"requestDate": "2026-09-04T10:00:00",
},
})(w, r)
}
func (m *mockOmbi) createTV(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
@@ -667,6 +947,47 @@ func (m *mockOmbi) userGet(w http.ResponseWriter, r *http.Request) {
}
}
func (m *mockOmbi) plexLibraries(w http.ResponseWriter, r *http.Request) {
if r.PathValue("machineId") == "broken" {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"successful":false,"message":"could not reach server"}`))
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"successful":true,"data":[{"id":"3","key":"3","type":"show","title":"TV Shows"},{"id":"1","key":"1","type":"movie","title":"Movies"}]}`))
}
func (m *mockOmbi) stats(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("from") == "" || r.URL.Query().Get("to") == "" {
m.jsonErr(w, http.StatusInternalServerError, "Object reference not set to an instance of an object")
return
}
m.fixed(`{"totalRequests":9,"totalMovieRequests":4,"totalTvRequests":5,"totalIssues":0,"completedRequestsMovies":2,"completedRequestsTv":1,"completedRequests":3}`)(w, r)
}
func (m *mockOmbi) testcron(w http.ResponseWriter, r *http.Request) {
var body struct {
Expression string `json:"expression"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
m.jsonErr(w, http.StatusBadRequest, "invalid JSON")
return
}
fields := strings.Fields(body.Expression)
valid := len(fields) >= 6 && len(fields) <= 7 &&
(fields[3] == "?") != (fields[5] == "?")
if valid {
m.fixed(`{"success":true}`)(w, r)
return
}
m.fixed(fmt.Sprintf(`{"success":false,"message":%q}`, "CRON Expression "+body.Expression+" is not valid"))(w, r)
}
func (m *mockOmbi) logsRead(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte("2026-09-19 10:00:00 [INF] boot\n2026-09-19 10:01:00 [INF] tick\n"))
}
// mockEnv returns the server env pointing at this mock in the
// requested mode, plus all three bundles.
func (m *mockOmbi) env() map[string]string {
+4 -4
View File
@@ -160,16 +160,16 @@ type SearchMovieExtraInfoRefineModel struct {
LanguageCode string `json:"languageCode"`
}
// DiscoverModel — POST /api/v2/Search/advancedSearch/movie/{pos}/{amt}
// The optional upstream `type` field is intentionally omitted until
// its semantics are verified.
// DiscoverModel — POST /api/v2/Search/advancedSearch/movie/{pos}/{amt}.
// Ombi uses Type to choose its downstream TMDB discover route; omitting it
// produces an empty result set.
type DiscoverModel struct {
Type string `json:"type"`
ReleaseYear *int `json:"releaseYear,omitempty"`
Decade *int `json:"decade,omitempty"`
GenreIDs []int `json:"genreIds,omitempty"`
KeywordIDs []int `json:"keywordIds,omitempty"`
WatchProviders []int `json:"watchProviders,omitempty"`
Companies []int `json:"companies,omitempty"`
}
// --- User / admin bodies ---
+2 -1
View File
@@ -26,6 +26,7 @@ type DiscoverArgs struct {
Language string `json:"language,omitempty"`
CollectionID *int `json:"collection_id,omitempty"`
PersonID *int `json:"person_id,omitempty"`
PersonName string `json:"person_name,omitempty"`
ArtistID string `json:"artist_id,omitempty"`
Filters *DiscoverFilters `json:"filters,omitempty"`
Page *Page `json:"page,omitempty"`
@@ -112,7 +113,7 @@ type LibraryArgs struct {
// read_server
type ServerArgs struct {
Action string `json:"action"` // status|status_info|about|update_info|update_check|news|landing|features|stats|cron_validate
Action string `json:"action"` // status|status_info|about|update_info|update_check|landing|features|stats|cron_validate
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
Expression string `json:"expression,omitempty"`
+1 -1
View File
@@ -132,7 +132,7 @@ func (o *op) httpErr(resp *http.Response, raw []byte) *ToolResult {
default:
if st >= 500 {
e.Code, e.Message, e.Retryable = "UPSTREAM_REJECTED",
fmt.Sprintf("upstream error (HTTP %d)", st), true
fmt.Sprintf("upstream error (HTTP %d)", st)+sanitizedDetail(raw), true
} else {
e.Code, e.Message, e.Retryable = "UPSTREAM_REJECTED",
fmt.Sprintf("upstream returned HTTP %d", st), false
+181 -25
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"ombi-mcp/internal/ombi"
)
@@ -78,6 +80,9 @@ func (o *op) discoverBrowse(a *DiscoverArgs) *ToolResult {
if fail != nil {
return fail
}
if a.Category == "requested" && len(arr) == 0 {
return o.discoverRequestedFallback(a, media)
}
items := make([]Media, 0, len(arr))
for _, m := range arr {
// v2 browse routes are TMDB-keyed for both movie and tv.
@@ -88,6 +93,87 @@ func (o *op) discoverBrowse(a *DiscoverArgs) *ToolResult {
return o.ok(&MediaPage{Kind: "media_page", Items: items, Page: pg})
}
// discoverRequestedFallback uses Ombi's bounded, reliable recent-request
// feed when its server-paged requested-browse route returns an empty page.
// The feed has no total or continuation contract, so its paging is local and
// deliberately reports those values as unknown.
func (o *op) discoverRequestedFallback(a *DiscoverArgs, media string) *ToolResult {
raw, fail := o.call("GET", "/api/v2/Requests/recentlyRequested", nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return fail
}
wantType := 1 // movie
if media == "tv" {
wantType = 0
}
items := make([]Media, 0, len(arr))
type pendingTV struct {
idx int
key tvRecentKey
}
pending := []pendingTV{}
for _, m := range arr {
kind, ok := toInt(m["type"])
if !ok || kind != wantType {
continue
}
requested := true
it := Media{
Media: media,
Identifiers: []Identifier{},
Title: jstr(m, "title"),
Overview: jstr(m, "overview"),
Available: jbool(m, "available"),
Requested: &requested,
}
if y := yearOf(jstr(m, "releaseDate")); y != nil {
it.Year = y
}
it.Identifiers = addID(it.Identifiers, "tmdb", m["mediaId"])
if id, ok := toInt(m["requestId"]); ok && id > 0 {
kind := "movie"
if media == "tv" {
// Recent TV requestId is a child request id — resolve
// to the parent below; tv_child stays as the truthful
// fallback when no parent can be found.
kind = "tv_child"
pending = append(pending, pendingTV{
len(items), tvRecentKey{requestID: id, mediaID: jstr(m, "mediaId")}})
}
it.RequestTargets = []OutTarget{{Kind: kind, ID: id}}
}
for _, artwork := range []string{jstr(m, "posterPath"), jstr(m, "background")} {
if artwork != "" {
it.ArtworkURIs = appendIfMissing(it.ArtworkURIs, artwork)
}
}
items = append(items, it)
}
if len(pending) > 0 {
keys := make([]tvRecentKey, len(pending))
for i, p := range pending {
keys[i] = p.key
}
parents := o.resolveRecentTVParents(keys)
for i, p := range pending {
if pid := jint(parents[i], "id"); pid != nil && *pid > 0 {
items[p.idx].RequestTargets = []OutTarget{{Kind: "tv_parent", ID: *pid}}
}
}
}
win, _ := localWindow(o, items, a.Page, "media")
o.truncated = true
o.warnf("requested browse fell back to Ombi's bounded recently-requested feed")
offset, limit := bounds(a.Page)
pg := &Paging{Offset: offset, Limit: limit, Returned: len(win),
Mode: "local", Unit: "media"}
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
}
func (o *op) discoverSimilar(a *DiscoverArgs) *ToolResult {
if a.TmdbID == nil || *a.TmdbID < 1 {
return o.invalid("tmdb_id", "tmdb_id must be a positive integer")
@@ -146,6 +232,10 @@ func (o *op) discoverCredits(a *DiscoverArgs) *ToolResult {
if a.PersonID == nil || *a.PersonID < 1 {
return o.invalid("person_id", "person_id must be a positive integer")
}
personName := strings.TrimSpace(a.PersonName)
if personName == "" || len(personName) > 200 {
return o.invalid("person_name", "person_name must contain 1 to 200 characters")
}
var media string
switch a.Media {
case "movie", "tv":
@@ -162,10 +252,15 @@ func (o *op) discoverCredits(a *DiscoverArgs) *ToolResult {
if fail != nil {
return fail
}
// ActorCredits.cast/crew entries are credited works; emit each as a
// media record keeping the TMDB id and the person's role as a credit.
items := []Media{}
seen := map[int]bool{}
// Ombi's ActorCredits model omits the person's name and, for TV, drops
// TMDB's name fields. Keep each work's roles together, then enrich only
// the requested TV window through the TMDB-keyed details route.
type creditWork struct {
record map[string]any
credits []Credit
}
works := []creditWork{}
byID := map[int]int{}
add := func(arr []any, isCrew bool) {
for _, v := range arr {
cm, ok := v.(map[string]any)
@@ -173,39 +268,92 @@ func (o *op) discoverCredits(a *DiscoverArgs) *ToolResult {
continue
}
id, _ := toInt(cm["id"])
if id > 0 && seen[id] {
if id < 1 {
continue
}
if id > 0 {
seen[id] = true
}
it := Media{Media: media, Identifiers: []Identifier{},
Title: jstr(cm, "title", "original_title", "name"),
Overview: jstr(cm, "overview")}
it.Identifiers = addID(it.Identifiers, "tmdb", cm["id"])
if y := yearOf(jstr(cm, "release_date", "first_air_date")); y != nil {
it.Year = y
}
if u := jstr(cm, "poster_path"); u != "" {
it.ArtworkURIs = []string{u}
}
cr := Credit{PersonID: a.PersonID}
cr := Credit{Name: personName, PersonID: a.PersonID}
if isCrew {
cr.Role = jstr(cm, "job")
cr.Department = jstr(cm, "department")
} else {
cr.Role = jstr(cm, "character")
}
if cr.Role != "" || cr.Department != "" {
it.Credits = []Credit{cr}
idx, exists := byID[id]
if !exists {
idx = len(works)
byID[id] = idx
works = append(works, creditWork{record: cm})
}
items = append(items, it)
works[idx].credits = append(works[idx].credits, cr)
}
}
add(jarr(m, "cast"), false)
add(jarr(m, "crew"), true)
win, pg := localWindow(o, items, a.Page, "media")
return o.ok(&MediaPage{Kind: "media_page", Items: win, Page: pg})
win, pg := localWindow(o, works, a.Page, "media")
items := make([]Media, len(win))
projectTV := func(work creditWork) (Media, *ToolResult) {
id, _ := toInt(work.record["id"])
detailRaw, detailFail := o.call("GET",
"/api/v2/Search/tv/moviedb/"+segInt(id), nil, nil)
if detailFail != nil {
return Media{}, detailFail
}
detail, detailFail := o.decodeObject(detailRaw)
if detailFail != nil {
return Media{}, detailFail
}
it := projectSearchMedia(detail, "tv", "tmdb")
it.Identifiers = addID(it.Identifiers, "tmdb", work.record["id"])
it.Credits = work.credits
return it, nil
}
if media == "tv" {
jobs := make(chan int)
failures := make(chan *ToolResult, len(win))
workers := len(win)
if workers > 8 {
workers = 8
}
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for i := range jobs {
it, detailFail := projectTV(win[i])
if detailFail != nil {
failures <- detailFail
continue
}
items[i] = it
}
}()
}
for i := range win {
jobs <- i
}
close(jobs)
wg.Wait()
if len(failures) > 0 {
return <-failures
}
} else {
for i, work := range win {
it := Media{Media: "movie", Identifiers: []Identifier{},
Title: jstr(work.record, "title", "original_title"),
Overview: jstr(work.record, "overview")}
it.Identifiers = addID(it.Identifiers, "tmdb", work.record["id"])
if y := yearOf(jstr(work.record, "release_date")); y != nil {
it.Year = y
}
if u := jstr(work.record, "poster_path"); u != "" {
it.ArtworkURIs = []string{u}
}
it.Credits = work.credits
items[i] = it
}
}
return o.ok(&MediaPage{Kind: "media_page", Items: items, Page: pg})
}
func (o *op) discoverArtistAlbums(a *DiscoverArgs) *ToolResult {
@@ -234,19 +382,27 @@ func (o *op) discoverAdvanced(a *DiscoverArgs) *ToolResult {
if f == nil {
return o.invalid("filters", "filters object is required")
}
if f.ReleaseYear != nil && *f.ReleaseYear < 1901 {
return o.invalid("filters.release_year",
"release_year must be 1901 or later; use decade for earlier periods")
}
if f.ReleaseYear != nil && f.Decade != nil {
if *f.ReleaseYear < *f.Decade || *f.ReleaseYear > *f.Decade+9 {
return o.invalid("filters",
"release_year %d does not fall inside decade %d", *f.ReleaseYear, *f.Decade)
}
}
if len(f.CompanyIDs) > 0 {
return o.invalid("filters.company_ids",
"company_ids is unsupported because Ombi does not apply it")
}
body := ombi.DiscoverModel{
Type: "movie",
ReleaseYear: f.ReleaseYear,
Decade: f.Decade,
GenreIDs: f.GenreIDs,
KeywordIDs: f.KeywordIDs,
WatchProviders: f.WatchProviderIDs,
Companies: f.CompanyIDs,
}
pos, amt := bounds(a.Page)
raw, fail := o.call("POST",
+133 -23
View File
@@ -125,37 +125,105 @@ func (o *op) integrationOptions(a *IntegrationArgs) *ToolResult {
return o.refsOrScalar(raw, cat)
}
type refKeySet struct {
id []string
name []string
value []string
}
var defaultOptionRefKeys = refKeySet{
id: []string{"id", "value", "key"},
name: []string{"name", "label", "value", "path"},
value: nil,
}
var optionRefKeys = map[string]refKeySet{
"root_folder": {
id: []string{"id"},
name: []string{"path", "name", "label", "value"},
value: nil,
},
"plex_library": {
id: []string{"key", "id"},
name: []string{"title", "name", "label"},
value: nil,
},
"plex_server": {
id: []string{"machineId", "serverId", "id", "machineIdentifier", "key"},
name: []string{"serverName", "name", "title"},
value: []string{"serverId", "machineId"},
},
"emby_info": {
id: []string{"id"},
name: []string{"serverName", "name"},
value: nil,
},
"jellyfin_info": {
id: []string{"id"},
name: []string{"serverName", "name"},
value: nil,
},
"emby_libraries": {
id: []string{"id", "key"},
name: []string{"name", "title"},
value: nil,
},
"jellyfin_libraries": {
id: []string{"id", "key"},
name: []string{"name", "title"},
value: nil,
},
}
func refKeysFor(cat string, defaults refKeySet) refKeySet {
if ks, ok := optionRefKeys[cat]; ok {
return ks
}
return defaults
}
func isRejectedObject(m map[string]any) (bool, string) {
for _, k := range []string{"success", "successful"} {
if v, ok := m[k]; ok {
if b, ok := v.(bool); ok && !b {
msg := jstr(m, "message", "Message", "errorMessage", "ErrorMessage")
if msg == "" {
msg = "upstream operation was unsuccessful"
}
return true, sanitizeText(msg, maxSanitizedMsg)
}
}
}
return false, ""
}
// refsOrScalar projects an option response: arrays become reference
// items; scalars/objects become single records.
// items; objects become nested arrays or single records; scalars become
// single records.
func (o *op) refsOrScalar(raw []byte, cat string) *ToolResult {
if items, fail := o.refArray(raw,
[]string{"id", "value", "key"}, []string{"name", "label", "value"}, cat); fail == nil {
topKeys := refKeysFor(cat, defaultOptionRefKeys)
if items, fail := o.refArray(raw, topKeys.id, topKeys.name, cat); fail == nil {
return o.ok(&ReferencePage{Kind: "reference_page",
Items: items, Page: singlePage(len(items), "references")})
}
// Non-array bodies: scalar or single object → one reference.
if v, fail := o.decodeScalar(raw); fail == nil && v != nil {
r := Reference{Name: cat, Category: cat}
switch t := v.(type) {
case string, float64, bool:
r.Value = t
if m, fail := o.decodeObject(raw); fail == nil {
if rejected, msg := isRejectedObject(m); rejected {
return o.fail("UPSTREAM_REJECTED", msg, false)
}
return o.ok(&ReferencePage{Kind: "reference_page",
Items: []Reference{r}, Page: singlePage(1, "references")})
// Nested containers (e.g. CouchPotatoProfiles.list, Plex Libraries data, Media Server items)
// project their first array member list.
nestedDefaults := refKeySet{
id: []string{"_id", "id", "key", "value"},
name: []string{"label", "name", "title", "serverName", "path"},
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
// Nested containers (e.g. CouchPotatoProfiles.list) project
// their first array member list.
nestedKeys := refKeysFor(cat, nestedDefaults)
for _, v := range m {
if arr, ok := v.([]any); ok {
items := []Reference{}
for _, e := range arr {
if em, ok := e.(map[string]any); ok {
items = append(items, refOf(em,
[]string{"_id", "id", "value"}, []string{"label", "name"}, nil, cat))
items = append(items, o.refOf(em,
nestedKeys.id, nestedKeys.name, nestedKeys.value, cat))
}
}
items = capItems(o, items)
@@ -163,10 +231,25 @@ func (o *op) refsOrScalar(raw []byte, cat string) *ToolResult {
Items: items, Page: singlePage(len(items), "references")})
}
}
r := refOf(m, []string{"id"}, []string{"name"}, nil, cat)
fallbackDefaults := refKeySet{
id: []string{"id"},
name: []string{"name", "serverName", "title", "label", "path"},
}
fallbackKeys := refKeysFor(cat, fallbackDefaults)
r := o.refOf(m, fallbackKeys.id, fallbackKeys.name, fallbackKeys.value, cat)
return o.ok(&ReferencePage{Kind: "reference_page",
Items: []Reference{r}, Page: singlePage(1, "references")})
}
if v, fail := o.decodeScalar(raw); fail == nil && v != nil {
switch t := v.(type) {
case string, float64, bool:
r := Reference{Name: cat, Category: cat, Value: t}
return o.ok(&ReferencePage{Kind: "reference_page",
Items: []Reference{r}, Page: singlePage(1, "references")})
}
}
return o.fail("UPSTREAM_SCHEMA_MISMATCH", "upstream response was not a JSON array, object, or scalar", false)
}
func (o *op) integrationPlex(a *IntegrationArgs) *ToolResult {
var path, cat string
@@ -188,12 +271,39 @@ func (o *op) integrationPlex(a *IntegrationArgs) *ToolResult {
}
// refsOrUsers projects a response that may be an array of user-like
// objects or reference-like objects.
// objects or reference-like objects, or an object wrapper containing them.
func (o *op) refsOrUsers(raw []byte, cat string) *ToolResult {
arr, fail := o.decodeArray(raw)
if fail != nil {
m, objFail := o.decodeObject(raw)
if objFail != nil {
return fail
}
if rejected, msg := isRejectedObject(m); rejected {
return o.fail("UPSTREAM_REJECTED", msg, false)
}
var innerArr []map[string]any
for _, v := range m {
if a, ok := v.([]any); ok {
innerArr = make([]map[string]any, 0, len(a))
for _, elem := range a {
if em, ok := elem.(map[string]any); ok {
innerArr = append(innerArr, em)
}
}
break
}
}
if innerArr == nil {
return fail
}
arr = innerArr
}
refKeys := refKeysFor(cat, refKeySet{
id: []string{"id", "machineIdentifier", "key"},
name: []string{"name", "title"},
value: nil,
})
// User-shaped records (id+userName/username/email) → user_page.
users := []User{}
refs := []Reference{}
@@ -201,8 +311,8 @@ func (o *op) refsOrUsers(raw []byte, cat string) *ToolResult {
if jstr(m, "userName", "username", "email") != "" {
users = append(users, projectUser(m))
} else {
refs = append(refs, refOf(m,
[]string{"id", "machineIdentifier", "key"}, []string{"name", "title"}, nil, cat))
refs = append(refs, o.refOf(m,
refKeys.id, refKeys.name, refKeys.value, cat))
}
}
if len(users) > 0 && len(refs) == 0 {
+38 -12
View File
@@ -27,6 +27,34 @@ func logFileName(m map[string]any) string {
return jstr(m, "fileName", "filename", "name")
}
// logFileNames decodes a listing whose elements are bare file names on Ombi
// 4.53.x. Older deployments that return objects with a name field remain
// supported. Entries with no usable name are ignored.
func (o *op) logFileNames(raw []byte) ([]string, *ToolResult) {
var els []json.RawMessage
if err := json.Unmarshal(raw, &els); err != nil {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
"upstream response was not a JSON array: "+sanitizeErr(err), false)
}
names := []string{}
for _, el := range els {
var name string
if err := json.Unmarshal(el, &name); err == nil {
if name != "" {
names = append(names, name)
}
continue
}
var record map[string]any
if err := json.Unmarshal(el, &record); err == nil {
if name := logFileName(record); name != "" {
names = append(names, name)
}
}
}
return names, nil
}
// read_logs — list sanitized log file IDs or read a bounded,
// sanitized slice of a single log file.
func handleLogs(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
@@ -50,16 +78,10 @@ func (o *op) logsList() *ToolResult {
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
names, fail := o.logFileNames(raw)
if fail != nil {
return fail
}
names := []string{}
for _, m := range arr {
if s := logFileName(m); s != "" {
names = append(names, s)
}
}
sort.Strings(names)
out := &Logs{Kind: "logs", Files: []LogFile{}}
for _, n := range names {
@@ -97,13 +119,13 @@ func (o *op) logsRead(a *LogsArgs) *ToolResult {
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
names, fail := o.logFileNames(raw)
if fail != nil {
return fail
}
name := ""
for _, m := range arr {
if n := logFileName(m); n != "" && logFileID(n) == a.FileID {
for _, n := range names {
if logFileID(n) == a.FileID {
name = n
break
}
@@ -119,11 +141,15 @@ func (o *op) logsRead(a *LogsArgs) *ToolResult {
raw = raw[:maxLogBodyBytes]
o.truncated = true
}
text := sanitizeText(string(raw), maxLogBodyBytes)
lines := strings.Split(text, "\n")
// Sanitize individual lines so control characters and unbounded text do
// not leak while preserving the upstream line boundaries for pagination.
lines := strings.Split(string(raw), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
for i := range lines {
lines[i] = sanitizeText(lines[i], maxLogBodyBytes)
}
out := &Logs{Kind: "logs", Lines: []string{}, Offset: &offset}
if offset < len(lines) {
end := offset + limit
+119 -10
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"ombi-mcp/internal/ombi"
)
@@ -128,8 +130,22 @@ func (o *op) mediaDetails(a *mediaCallArgs) *ToolResult {
if fail != nil {
return fail
}
it := project(m)
// #7 request state overlay for tvdb
if t.Media == "tv" && t.Provider == "tvdb" {
reqVal := jbool(m, "requested")
// Upstream sends requestId 0 (not absent) on unrequested shows —
// a nil-only check keeps the overlay from ever firing on live.
rid := jint(m, "requestId")
if (reqVal == nil || !*reqVal) && (rid == nil || *rid == 0) {
id, _ := t.idInt()
o.overlayTVRequestState(&it, id, jstr(m, "imdbId"))
}
}
return o.ok(&MediaPage{Kind: "media_page",
Items: []Media{project(m)}, Page: singlePage(1, "media")})
Items: []Media{it}, Page: singlePage(1, "media")})
}
func (o *op) mediaByRequest(a *mediaCallArgs) *ToolResult {
@@ -205,23 +221,116 @@ func (o *op) mediaRatings(a *mediaCallArgs) *ToolResult {
raw, fail := o.call("GET",
fmt.Sprintf("/api/v2/Search/ratings/%s/%s/%d", media, seg(a.Name), *a.Year),
nil, nil)
if fail != nil && !ratingsFallbackEligible(fail) {
return fail
}
if fail == nil {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return o.fail("UPSTREAM_SCHEMA_MISMATCH",
fmt.Sprintf("upstream response was not a JSON object: %s", sanitizeErr(err)), false)
}
if m != nil {
if ratings := ratingReferences(m); len(ratings) > 0 {
return o.ratingPage(media, a.Name, *a.Year, ratings)
}
}
}
// Ombi 4.53.x still exposes the Rotten Tomatoes routes above, but their
// private upstream endpoints have been removed. Fall back to rating
// metadata already returned by Ombi's normal search providers rather than
// turning that dependency failure into an unusable MCP action.
page, fallbackFail := o.mediaRatingsFromSearch(media, a.Name, *a.Year)
if fallbackFail != nil {
if fail != nil {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
return fallbackFail
}
it := Media{Media: media, Identifiers: []Identifier{}, Title: a.Name, Year: a.Year}
for k, v := range m {
return o.ok(page)
}
func ratingsFallbackEligible(fail *ToolResult) bool {
if fail == nil || fail.Error == nil || fail.Error.HTTPStatus == nil {
return false
}
status := *fail.Error.HTTPStatus
return status == 404 || status >= 500
}
func ratingReferences(m map[string]any) []Reference {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
refs := []Reference{}
for _, k := range keys {
v := m[k]
if i, ok := toInt(v); ok {
it.Ratings = append(it.Ratings, Reference{Name: k, Value: i})
refs = append(refs, Reference{Name: k, Value: i})
} else if s, ok := v.(string); ok && s != "" {
it.Ratings = append(it.Ratings, Reference{Name: k, Value: s})
refs = append(refs, Reference{Name: k, Value: s})
}
}
return o.ok(&MediaPage{Kind: "media_page",
Items: []Media{it}, Page: singlePage(1, "media")})
return refs
}
func (o *op) mediaRatingsFromSearch(media, name string, year int) (*MediaPage, *ToolResult) {
raw, fail := o.call("GET", "/api/v1/Search/"+media+"/"+seg(name), nil, nil)
if fail != nil {
return nil, fail
}
arr, fail := o.decodeArray(raw)
if fail != nil {
return nil, fail
}
for _, m := range arr {
title := jstr(m, "title", "name")
resultYear := yearOf(jstr(m, "releaseDate", "firstAired", "releaseYear"))
if !strings.EqualFold(strings.TrimSpace(title), strings.TrimSpace(name)) ||
resultYear == nil || *resultYear != year {
continue
}
refs := []Reference{}
source := ""
if media == "movie" {
source = "TMDB"
if v, ok := m["voteAverage"].(float64); ok {
refs = append(refs, Reference{Name: "tmdb_vote_average", Value: v})
}
if v, ok := toInt(m["voteCount"]); ok {
refs = append(refs, Reference{Name: "tmdb_vote_count", Value: v})
}
} else {
source = "TVMaze"
if v, ok := toInt(m["siteRating"]); ok {
refs = append(refs, Reference{Name: "tvmaze_site_rating", Value: v})
}
}
o.warnf("Ombi's Rotten Tomatoes ratings endpoint was unavailable; ratings were read from %s search metadata", source)
if len(refs) == 0 {
o.warnf("the matching %s search result did not include rating metadata", media)
}
return &MediaPage{Kind: "media_page", Items: []Media{{
Media: media, Identifiers: []Identifier{}, Title: title, Year: resultYear, Ratings: refs,
}}, Page: singlePage(1, "media")}, nil
}
o.warnf("Ombi's Rotten Tomatoes ratings endpoint was unavailable and search returned no exact title/year match")
return &MediaPage{Kind: "media_page", Items: []Media{{
Media: media, Identifiers: []Identifier{}, Title: name, Year: &year, Ratings: []Reference{},
}}, Page: singlePage(1, "media")}, nil
}
func (o *op) ratingPage(media, title string, year int, ratings []Reference) *ToolResult {
return o.ok(&MediaPage{Kind: "media_page", Items: []Media{{
Media: media, Identifiers: []Identifier{}, Title: title, Year: &year, Ratings: ratings,
}}, Page: singlePage(1, "media")})
}
func (o *op) mediaStreaming(a *mediaCallArgs) *ToolResult {
+102 -22
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"strconv"
"strings"
"ombi-mcp/internal/ombi"
"ombi-mcp/internal/translate"
@@ -95,6 +96,12 @@ func toStr(v any) (string, bool) {
return s, true
case float64:
return strconv.FormatFloat(s, 'f', -1, 64), true
case int:
return strconv.Itoa(s), true
case int64:
return strconv.FormatInt(s, 10), true
case json.Number:
return s.String(), true
case bool:
return strconv.FormatBool(s), true
}
@@ -160,9 +167,27 @@ func addID(ids []Identifier, ns string, v any) []Identifier {
if !ok || s == "" || s == "0" {
return ids
}
for _, id := range ids {
if id.Namespace == ns && id.Value == s {
return ids
}
}
return append(ids, Identifier{Namespace: ns, Value: s})
}
// firstID returns the first present, non-zero identifier value among
// keys. Used so v2 browse/details payloads that only populate `id`
// still emit a labelled identifier, without inventing a namespace.
func firstID(m map[string]any, keys ...string) any {
for _, k := range keys {
s, ok := toStr(m[k])
if ok && s != "" && s != "0" {
return m[k]
}
}
return nil
}
// --- media projections ---
// projectSearchMedia projects the shared tail of SearchMovieViewModel,
@@ -184,16 +209,26 @@ func projectSearchMedia(m map[string]any, media, tvIDNS string) Media {
out.Genres = strList(jarr(m, "genre"))
switch media {
case "movie":
out.Identifiers = addID(out.Identifiers, "tmdb", m["theMovieDbId"])
// MovieFullInfoViewModel and v2 browse/collection members often
// carry the TMDB id only in `id`; theMovieDbId is the preferred
// field when present.
out.Identifiers = addID(out.Identifiers, "tmdb", firstID(m, "theMovieDbId", "id"))
out.Identifiers = addID(out.Identifiers, "imdb", m["imdbId"])
if rid, ok := toInt(m["requestId"]); ok && rid > 0 {
out.RequestTargets = []OutTarget{{Kind: "movie", ID: rid}}
}
case "tv":
out.Identifiers = addID(out.Identifiers, tvIDNS, m["theMovieDbId"])
// Label `id` with the same origin namespace as theMovieDbId so
// v2 browse (id=TMDB, theMovieDbId absent) emits tmdb, while v1
// TVMaze (id=TVDB) does not grow a bogus tmdb identifier.
out.Identifiers = addID(out.Identifiers, tvIDNS, firstID(m, "theMovieDbId", "id"))
out.Identifiers = addID(out.Identifiers, "tvdb", m["theTvDbId"])
out.Identifiers = addID(out.Identifiers, "imdb", m["imdbId"])
// seriesId is a TVMaze id only on the v1 TVMaze-backed routes.
// On the v2 moviedb route it echoes the TMDB id.
if tvIDNS == "tvdb" {
out.Identifiers = addID(out.Identifiers, "tvmaze", m["seriesId"])
}
if rid, ok := toInt(m["requestId"]); ok && rid > 0 {
out.RequestTargets = []OutTarget{{Kind: "tv_parent", ID: rid}}
}
@@ -227,7 +262,9 @@ func strList(a []any) []string {
}
// projectFullMovie enriches a movie projection with credits, genres,
// ratings and artwork from MovieFullInfoViewModel.
// ratings and artwork from MovieFullInfoViewModel. belongsToCollection.id
// is the collection's TMDB id, not the movie's, and is not emitted as
// a movie identifier.
func projectFullMovie(m map[string]any) Media {
out := projectSearchMedia(m, "movie", "tmdb")
out.Genres = namesOf(jarr(m, "genres"))
@@ -245,11 +282,6 @@ func projectFullMovie(m map[string]any) Media {
out.Ratings = append(out.Ratings, Reference{Name: "vote_count", Value: i})
}
}
if c := jobj(m, "belongsToCollection"); c != nil {
if id, ok := toInt(c["id"]); ok {
out.Identifiers = addID(out.Identifiers, "tmdb", id)
}
}
for _, k := range []string{"backdropPath", "posterPath"} {
if u := jstr(m, k); u != "" {
out.ArtworkURIs = appendIfMissing(out.ArtworkURIs, u)
@@ -380,10 +412,13 @@ func appendIfMissing(s []string, v string) []string {
// AlbumRequest / RecentlyRequestedModel into the request family.
func (o *op) projectRequest(m map[string]any, kind string) Request {
r := Request{Target: OutTarget{Kind: kind}}
if id, ok := toInt(m["id"]); ok {
// requestId is the Ombi request id on RecentlyRequestedModel and
// on some request entities; `id` is the request id on list/get
// entities but a provider id on recent TV payloads. Prefer
// requestId so a provider id never occupies the target slot.
if id, ok := toInt(m["requestId"]); ok && id > 0 {
r.Target.ID = id
}
if id, ok := toInt(m["requestId"]); ok && r.Target.ID == 0 {
} else if id, ok := toInt(m["id"]); ok && id > 0 {
r.Target.ID = id
}
r.Title = jstr(m, "title", "artistName")
@@ -401,6 +436,7 @@ func (o *op) projectRequest(m map[string]any, kind string) Request {
switch kind {
case "movie":
r.Identifiers = addID(r.Identifiers, "tmdb", m["theMovieDbId"])
r.Identifiers = addID(r.Identifiers, "tmdb", m["mediaId"])
r.Identifiers = addID(r.Identifiers, "imdb", m["imdbId"])
r.Is4K = jbool(m, "is4kRequest")
r.Approved4K = jbool(m, "approved4K")
@@ -408,10 +444,20 @@ func (o *op) projectRequest(m map[string]any, kind string) Request {
r.Denied4K = jbool(m, "denied4K")
case "tv_parent", "tv_child":
r.Identifiers = addID(r.Identifiers, "tvdb", m["tvDbId"])
r.Identifiers = addID(r.Identifiers, "tvdb", m["theTvDbId"])
r.Identifiers = addID(r.Identifiers, "tmdb", m["externalProviderId"])
r.Identifiers = addID(r.Identifiers, "tmdb", m["mediaId"])
r.Identifiers = addID(r.Identifiers, "imdb", m["imdbId"])
// ChildRequests carries no top-level provider ids — they live
// on the embedded parent record.
if pr := jobj(m, "parentRequest"); pr != nil {
r.Identifiers = addID(r.Identifiers, "tvdb", pr["tvDbId"])
r.Identifiers = addID(r.Identifiers, "tmdb", pr["externalProviderId"])
r.Identifiers = addID(r.Identifiers, "imdb", pr["imdbId"])
}
case "album":
r.Identifiers = addID(r.Identifiers, "musicbrainz", m["foreignAlbumId"])
r.Identifiers = addID(r.Identifiers, "musicbrainz", m["mediaId"])
}
if srs := jarr(m, "seasonRequests"); len(srs) > 0 {
r.Seasons = o.seasonsOf(srs)
@@ -566,38 +612,72 @@ func (o *op) projectCalendarEntry(m map[string]any) CalendarEntry {
// refOf projects one upstream object into a reference record using
// the first present key from each candidate list.
func refOf(m map[string]any, idKeys, nameKeys []string, val any, category string) Reference {
func (o *op) refOf(m map[string]any, idKeys, nameKeys, valueKeys []string, category string) Reference {
r := Reference{Category: category}
var idRaw any
for _, k := range idKeys {
if s, ok := toStr(m[k]); ok && s != "" {
r.ID = s
idRaw = m[k]
break
}
}
var firstCorrupted string
for _, k := range nameKeys {
if s, ok := m[k].(string); ok && s != "" {
if !corrupted(s) {
r.Name = s
firstCorrupted = ""
break
}
if firstCorrupted == "" {
firstCorrupted = s
}
if val == nil {
r.Value = firstScalar(m)
} else {
r.Value = val
}
}
if r.Name == "" && firstCorrupted != "" {
r.Name = firstCorrupted
if o != nil {
o.warnf("upstream %s label appears corrupted (id %q)", category, r.ID)
}
}
if len(valueKeys) > 0 {
r.Value = scalarAt(m, valueKeys)
} else if idRaw != nil {
r.Value = idRaw
} else if r.Name != "" {
r.Value = r.Name
}
return r
}
// firstScalar returns the first scalar property value in a map for
// fallback reference values; iteration order makes this best-effort,
// so callers should prefer explicit keys where known.
func firstScalar(m map[string]any) any {
for _, v := range m {
func corrupted(s string) bool {
t := strings.TrimSpace(s)
if t == "" {
return false
}
if strings.ContainsRune(s, '\uFFFD') {
return true
}
for _, r := range t {
if r != '?' {
return false
}
}
return true
}
func scalarAt(m map[string]any, keys []string) any {
for _, k := range keys {
if v, ok := m[k]; ok {
switch v.(type) {
case string, float64, bool:
case string, float64, bool, int, int64:
return v
}
}
}
return nil
}
+334
View File
@@ -0,0 +1,334 @@
package tools
import (
"testing"
)
func idMap(ids []Identifier) map[string]string {
out := map[string]string{}
for _, id := range ids {
out[id.Namespace] = id.Value
}
return out
}
func countNS(ids []Identifier, ns string) int {
n := 0
for _, id := range ids {
if id.Namespace == ns {
n++
}
}
return n
}
func TestProjectSearchMediaMovieFallsBackToID(t *testing.T) {
m := projectSearchMedia(map[string]any{
"id": 348, "title": "Alien", "imdbId": "tt0078748",
}, "movie", "tmdb")
ids := idMap(m.Identifiers)
if ids["tmdb"] != "348" || ids["imdb"] != "tt0078748" {
t.Fatalf("identifiers = %v", m.Identifiers)
}
}
func TestProjectFullMovieDedupsImdbAndSkipsCollectionID(t *testing.T) {
m := projectFullMovie(map[string]any{
"id": 348, "title": "Alien", "imdbId": "tt0078748",
"externalIds": map[string]any{"imdbId": "tt0078748"},
"belongsToCollection": map[string]any{"id": 8091, "name": "Alien Collection"},
})
ids := idMap(m.Identifiers)
if ids["tmdb"] != "348" {
t.Errorf("tmdb = %q, want 348 (not collection 8091)", ids["tmdb"])
}
if _, ok := ids["tmdb"]; ok && countNS(m.Identifiers, "tmdb") != 1 {
t.Errorf("tmdb emitted %d times: %v", countNS(m.Identifiers, "tmdb"), m.Identifiers)
}
if countNS(m.Identifiers, "imdb") != 1 {
t.Errorf("imdb emitted %d times: %v", countNS(m.Identifiers, "imdb"), m.Identifiers)
}
}
func TestProjectSearchMediaTVV2IDFallbackIsTMDBNotTVMaze(t *testing.T) {
m := projectSearchMedia(map[string]any{
"id": 1668, "title": "Reacher", "seriesId": 1668,
}, "tv", "tmdb")
ids := idMap(m.Identifiers)
if ids["tmdb"] != "1668" {
t.Errorf("tmdb = %q", ids["tmdb"])
}
if _, ok := ids["tvmaze"]; ok {
t.Errorf("v2 seriesId must not be labelled tvmaze: %v", m.Identifiers)
}
if _, ok := ids["tvdb"]; ok {
t.Errorf("unexpected tvdb: %v", m.Identifiers)
}
}
func TestProjectSearchMediaTVV1KeepsTVMazeAndDoesNotInventTMDB(t *testing.T) {
m := projectSearchMedia(map[string]any{
"id": 81189, "theMovieDbId": 81189, "seriesId": 169,
"imdbId": "tt0903747", "title": "Breaking Bad",
}, "tv", "tvdb")
ids := idMap(m.Identifiers)
if ids["tvdb"] != "81189" || ids["tvmaze"] != "169" || ids["imdb"] != "tt0903747" {
t.Errorf("identifiers = %v", m.Identifiers)
}
if _, ok := ids["tmdb"]; ok {
t.Errorf("v1 id fallback must not emit tmdb: %v", m.Identifiers)
}
}
func TestProjectFullTVTMDBDoesNotEchoSeriesIdAsTVMaze(t *testing.T) {
o := &op{}
m := o.projectFullTV(map[string]any{
"id": 1206, "theMovieDbId": 1206, "seriesId": 1206,
"theTvDbId": 75150, "imdbId": "tt0227882",
"externalIds": map[string]any{"imdbId": "tt0227882", "tvdbId": 75150},
"title": "Button Moon",
}, "tmdb")
ids := idMap(m.Identifiers)
if ids["tmdb"] != "1206" || ids["tvdb"] != "75150" || ids["imdb"] != "tt0227882" {
t.Errorf("identifiers = %v", m.Identifiers)
}
if v, ok := ids["tvmaze"]; ok {
t.Errorf("bogus tvmaze %q (echoed TMDB id)", v)
}
if countNS(m.Identifiers, "imdb") != 1 {
t.Errorf("imdb emitted %d times: %v", countNS(m.Identifiers, "imdb"), m.Identifiers)
}
}
func TestProjectMultiResultArtistCaseInsensitive(t *testing.T) {
o := &op{}
m := o.projectMultiResult(map[string]any{
"id": "8dc08e7c-41f1-4a04-97dc-eb00d91d1d2f",
"mediaType": "Artist", "title": "Radiohead",
})
if m.Media != "artist" {
t.Errorf("media = %q, want artist", m.Media)
}
ids := idMap(m.Identifiers)
if ids["musicbrainz"] != "8dc08e7c-41f1-4a04-97dc-eb00d91d1d2f" {
t.Errorf("identifiers = %v", m.Identifiers)
}
if _, ok := ids["provider_unknown"]; ok {
t.Errorf("Artist mapped as provider_unknown: %v", m.Identifiers)
}
}
func TestProjectRequestPrefersRequestIDOverProviderID(t *testing.T) {
o := &op{}
r := o.projectRequest(map[string]any{
"id": 259032, "requestId": 88, "title": "Seven Up!",
"mediaId": "259032", "tvDbId": 259032,
}, "tv_parent")
if r.Target.Kind != "tv_parent" || r.Target.ID != 88 {
t.Errorf("target = %+v, want id 88", r.Target)
}
ids := idMap(r.Identifiers)
if ids["tvdb"] != "259032" {
t.Errorf("tvdb identifier = %q", ids["tvdb"])
}
if ids["tmdb"] != "259032" {
t.Errorf("mediaId tmdb identifier = %q", ids["tmdb"])
}
}
func TestProjectRequestListStillUsesEntityID(t *testing.T) {
o := &op{}
r := o.projectRequest(map[string]any{
"id": 10, "theMovieDbId": 27205, "title": "Inception",
}, "movie")
if r.Target.ID != 10 {
t.Errorf("target.id = %d, want 10", r.Target.ID)
}
}
func TestMergeTVRequestState(t *testing.T) {
it := &Media{
Seasons: []SeasonOut{
{
SeasonNumber: 1,
Episodes: []EpisodeOut{
{EpisodeNumber: 1},
{EpisodeNumber: 2},
},
},
},
}
parent := map[string]any{
"id": 909,
"tvDbId": 81189,
"childRequests": []any{
map[string]any{
"seasonRequests": []any{
map[string]any{
"seasonNumber": 1,
"episodes": []any{
map[string]any{"episodeNumber": 1, "requested": true, "available": true},
},
},
},
},
},
}
matched := mergeTVRequestState(it, parent, 81189, "")
if !matched {
t.Fatalf("expected match")
}
if it.Requested == nil || !*it.Requested {
t.Errorf("expected media Requested=true")
}
if len(it.RequestTargets) != 1 || it.RequestTargets[0].ID != 909 {
t.Errorf("expected target ID 909")
}
if it.Seasons[0].Episodes[0].Requested == nil || !*it.Seasons[0].Episodes[0].Requested {
t.Errorf("expected ep1 Requested=true")
}
if it.Seasons[0].Episodes[0].Available == nil || !*it.Seasons[0].Episodes[0].Available {
t.Errorf("expected ep1 Available=true")
}
if it.Seasons[0].Episodes[1].Requested != nil {
t.Errorf("expected ep2 Requested=nil")
}
}
func TestMergeTVRequestStateNoMatch(t *testing.T) {
it := &Media{}
parent := map[string]any{"tvDbId": 99999}
matched := mergeTVRequestState(it, parent, 81189, "")
if matched {
t.Fatalf("expected no match")
}
if it.Requested != nil {
t.Errorf("expected Requested=nil on no match")
}
}
func TestTVParentMatchChildIDBeatsProviderID(t *testing.T) {
parent := map[string]any{
"id": 909, "tvDbId": 259032, "externalProviderId": 118680,
"childRequests": []any{
map[string]any{
"id": 88,
"seasonRequests": []any{
map[string]any{"childRequestId": 88, "seasonNumber": 1},
},
},
},
}
if got := tvParentMatch(parent, tvRecentKey{requestID: 88, mediaID: "1396"}); got != tvMatchChild {
t.Errorf("real child id must match tier 1, got %d", got)
}
if got := tvParentMatch(parent, tvRecentKey{requestID: 259032}); got != tvMatchProvider {
t.Errorf("provider-shaped child pk falls back to tier 2 without embedded child, got %d", got)
}
if got := tvParentMatch(parent, tvRecentKey{requestID: 0, mediaID: "118680"}); got != tvMatchProvider {
t.Errorf("mediaId provider match = %d, want %d", got, tvMatchProvider)
}
if got := tvParentMatch(parent, tvRecentKey{requestID: 777, mediaID: "999"}); got != tvMatchNone {
t.Errorf("unrelated key matched: %d", got)
}
}
func TestTVParentMatchProviderShapedChildPK(t *testing.T) {
// Live rows: upstream persists the provider id as the child PK, so
// childRequests[].id equals tvDbId/externalProviderId.
parent := map[string]any{
"id": 909, "tvDbId": 259032,
"childRequests": []any{
map[string]any{"id": 259032},
},
}
if got := tvParentMatch(parent, tvRecentKey{requestID: 259032}); got != tvMatchChild {
t.Errorf("provider-shaped child id is still an exact tier-1 match, got %d", got)
}
}
func TestRefOfDeterministicValue(t *testing.T) {
o := &op{}
m := map[string]any{
"id": 53,
"name": "Thriller",
"logoPath": "/x",
"enabled": false,
}
for i := 0; i < 50; i++ {
r := o.refOf(m, []string{"id"}, []string{"name"}, nil, "genre")
if r.ID != "53" {
t.Fatalf("iteration %d: id = %q, want \"53\"", i, r.ID)
}
if r.Name != "Thriller" {
t.Fatalf("iteration %d: name = %q, want \"Thriller\"", i, r.Name)
}
if r.Value != 53 {
t.Fatalf("iteration %d: value = %v (%T), want 53", i, r.Value, r.Value)
}
}
}
func TestRefOfValueKeysOverride(t *testing.T) {
o := &op{}
m := map[string]any{
"id": 53,
"name": "Thriller",
"customVal": "override",
}
r := o.refOf(m, []string{"id"}, []string{"name"}, []string{"customVal"}, "genre")
if r.Value != "override" {
t.Fatalf("value = %v, want \"override\"", r.Value)
}
}
func TestRefOfCorruptedNameSkip(t *testing.T) {
o := &op{}
m := map[string]any{
"id": 1,
"english_name": "?????",
"name": "Clean",
}
r := o.refOf(m, []string{"id"}, []string{"english_name", "name"}, nil, "language")
if r.Name != "Clean" {
t.Fatalf("name = %q, want \"Clean\"", r.Name)
}
if len(o.warn) != 0 {
t.Fatalf("unexpected warnings: %v", o.warn)
}
o = &op{}
m2 := map[string]any{
"id": 2,
"english_name": "Bad\uFFFDName",
"name": "CleanAlternate",
}
r2 := o.refOf(m2, []string{"id"}, []string{"english_name", "name"}, nil, "language")
if r2.Name != "CleanAlternate" {
t.Fatalf("name = %q, want \"CleanAlternate\"", r2.Name)
}
if len(o.warn) != 0 {
t.Fatalf("unexpected warnings: %v", o.warn)
}
}
func TestRefOfAllCorruptedEmitsAndWarns(t *testing.T) {
o := &op{}
m := map[string]any{
"id": "ky",
"english_name": "?????",
"name": "?????",
}
r := o.refOf(m, []string{"id"}, []string{"english_name", "name"}, nil, "language")
if r.Name != "?????" {
t.Fatalf("name = %q, want \"?????\"", r.Name)
}
if len(o.warn) == 0 {
t.Fatalf("expected warning for corrupted name, got none")
}
expectedWarn := "upstream language label appears corrupted (id \"ky\")"
if o.warn[0] != expectedWarn {
t.Fatalf("warn = %q, want %q", o.warn[0], expectedWarn)
}
}
+2 -2
View File
@@ -67,7 +67,7 @@ func (o *op) refArray(raw []byte, idKeys, nameKeys []string, cat string) ([]Refe
}
items := make([]Reference, 0, len(arr))
for _, m := range arr {
items = append(items, refOf(m, idKeys, nameKeys, nil, cat))
items = append(items, o.refOf(m, idKeys, nameKeys, nil, cat))
}
return capItems(o, items), nil
}
@@ -114,7 +114,7 @@ func (o *op) refKeyword(a *ReferenceArgs) *ToolResult {
if fail != nil {
return fail
}
r := refOf(m, []string{"id"}, []string{"name"}, nil, "keyword")
r := o.refOf(m, []string{"id"}, []string{"name"}, nil, "keyword")
return o.ok(&ReferencePage{Kind: "reference_page",
Items: []Reference{r}, Page: singlePage(1, "references")})
}
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"strings"
)
//go:generate go run ../../tools/schemagen
//go:generate sh -c "cd ../.. && go run ./tools/schemagen"
//go:embed schemas.json
var schemasJSON []byte
@@ -51,7 +51,7 @@ var registry = []ToolDef{
{"read_votes", "core", "Global vote list or votes on a request.", true, false, true, true, []string{"vote_page"}, handleVotes},
{"read_users", "core", "Self, authorized user lookup, claims, online users, preference read.", true, false, true, true, []string{"user_page", "reference_page"}, handleUsers},
{"read_library", "core", "Recent additions, calendar, artwork.", true, false, true, true, []string{"media_page", "calendar_page", "artwork_page"}, handleLibrary},
{"read_server", "core", "Status, version, features, news, stats, cron validation.", true, false, true, true, []string{"metrics", "reference_page"}, handleServer},
{"read_server", "core", "Status, version, features, stats, cron validation.", true, false, true, true, []string{"metrics", "reference_page"}, handleServer},
{"read_integration", "core", "Saved ARR options and authorized media-server metadata.", true, false, true, true, []string{"reference_page", "user_page"}, handleIntegration},
{"write_request_create", "core", "One media request or explicit collection request.", false, false, false, true, []string{"mutation"}, handleRequestCreate},
{"write_request_subscribe", "core", "Subscribe/unsubscribe.", false, false, false, true, []string{"mutation"}, handleRequestSubscribe},
+68 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
)
// read_requests — v2 list/status routes, single gets, TV children,
@@ -163,6 +164,25 @@ func (o *op) requestsSearch(a *RequestsListArgs) *ToolResult {
return o.invalid("media", "media must be movie|tv|album")
}
raw, fail := o.call("GET", path+seg(a.Query), nil, nil)
// #10 tv search fallback
if fail != nil && a.Media == "tv" {
items := []Request{}
q := strings.ToLower(a.Query)
_, failScan := o.eachTVRequestParent(func(p map[string]any) bool {
if strings.Contains(strings.ToLower(jstr(p, "title")), q) {
items = append(items, o.projectRequest(p, kind))
}
return false
})
if failScan != nil {
return fail // surface the original error
}
o.warnf("primary tv search route failed; fell back to parent scan")
win, pg := localWindow(o, items, a.Page, "requests")
return o.ok(&RequestPage{Kind: "request_page", Items: win, Page: pg})
}
if fail != nil {
return fail
}
@@ -188,6 +208,11 @@ func (o *op) requestsRecent(a *RequestsListArgs) *ToolResult {
return fail
}
items := []Request{}
type pendingTV struct {
idx int
key tvRecentKey
}
pending := []pendingTV{}
for _, m := range arr {
code, label := o.requestTypeTwin(m["type"])
kind := "movie"
@@ -203,12 +228,54 @@ func (o *op) requestsRecent(a *RequestsListArgs) *ToolResult {
kind = "movie"
}
r := o.projectRequest(m, kind)
if id, ok := toInt(m["requestId"]); ok {
if kind == "tv_parent" {
// On TV rows RecentlyRequestedModel.requestId is the CHILD
// request id (upstream persists the provider id as the
// child PK for new-request children), never the parent id
// `get` needs. Zero whatever requestId/`id` projected and
// resolve the parent through the v1 parent list; the child
// id stays available as an ombi_tv_child identifier.
r.Target.ID = 0
key := tvRecentKey{mediaID: jstr(m, "mediaId")}
if id, ok := toInt(m["requestId"]); ok && id > 0 {
key.requestID = id
r.Identifiers = addID(r.Identifiers, "ombi_tv_child", id)
}
pending = append(pending, pendingTV{len(items), key})
} else {
// RecentlyRequestedModel.requestId is the request id for
// movie/album rows; a stray provider `id` must not occupy
// the target even when requestId is absent.
if id, ok := toInt(m["requestId"]); ok && id > 0 {
r.Target.ID = id
} else {
r.Target.ID = 0
}
}
r.RequestedUserID = jstr(m, "userId")
items = append(items, r)
}
if len(pending) > 0 {
keys := make([]tvRecentKey, len(pending))
for i, p := range pending {
keys[i] = p.key
}
parents := o.resolveRecentTVParents(keys)
for i, p := range pending {
r := &items[p.idx]
parent, ok := parents[i]
pid := jint(parent, "id")
if !ok || pid == nil || *pid < 1 {
o.warnf("recent tv item %q has no resolvable parent request; target.id left unknown",
r.Title)
continue
}
r.Target.ID = *pid
r.Identifiers = addID(r.Identifiers, "tvdb", parent["tvDbId"])
r.Identifiers = addID(r.Identifiers, "tmdb", parent["externalProviderId"])
r.Identifiers = addID(r.Identifiers, "imdb", parent["imdbId"])
}
}
win, pg := localWindow(o, items, a.Page, "requests")
return o.ok(&RequestPage{Kind: "request_page", Items: win, Page: pg})
}
+266
View File
@@ -0,0 +1,266 @@
package tools
import (
"bytes"
"fmt"
"strconv"
)
// eachTVRequestParent iterates GET /api/v1/Request/tv/{count}/{pos}/1/0/0
// pages (filters ignored upstream; orderType 1 keeps the path valid).
// fn(parent) returning true stops the scan. Returns truncated=true when
// the 10-page cap is hit without exhausting the list.
func (o *op) eachTVRequestParent(fn func(map[string]any) bool) (truncated bool, err *ToolResult) {
count := 100
pos := 0
pages := 0
for pages < 10 {
path := fmt.Sprintf("/api/v1/Request/tv/%d/%d/1/0/0", count, pos)
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return false, fail
}
arr, fail := o.decodeTVParentPage(raw)
if fail != nil {
return false, fail
}
for _, m := range arr {
if fn(m) {
return false, nil
}
}
if len(arr) < count {
return false, nil // exhausted
}
pos += count
pages++
}
return true, nil // hit cap
}
// decodeTVParentPage decodes one page of the v1 TV parent list. The
// documented shape is RequestsViewModel<TvRequests> — a
// {"collection":[...],"total":N} object — but a bare array is tolerated
// for versions or proxies that unwrap it.
func (o *op) decodeTVParentPage(raw []byte) ([]map[string]any, *ToolResult) {
trim := bytes.TrimSpace(raw)
if len(trim) > 0 && trim[0] == '[' {
return o.decodeArray(raw)
}
vm, fail := o.decodeObject(raw)
if fail != nil {
return nil, fail
}
if _, present := vm["collection"]; !present {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
"tv parent page object lacked a collection", false)
}
arr := jarr(vm, "collection")
out := make([]map[string]any, 0, len(arr))
for _, v := range arr {
if m, ok := v.(map[string]any); ok {
out = append(out, m)
}
}
return out, nil
}
// tvRecentKey carries the identity hints one recentlyRequested TV row
// provides: requestId is the CHILD request id (Ombi builds recent TV
// rows from child requests and persists the provider id as the child
// PK for new-request children), mediaId the parent's
// externalProviderId. The parent request id itself is never sent.
type tvRecentKey struct {
requestID int
mediaID string
}
const (
tvMatchNone = iota
tvMatchChild
tvMatchProvider
)
// tvParentChildIDs collects the real child request ids a TvRequests
// parent record embeds: childRequests[].id plus
// seasonRequests[].childRequestId.
func tvParentChildIDs(p map[string]any) map[int]bool {
ids := map[int]bool{}
for _, cr := range jarr(p, "childRequests") {
crm, ok := cr.(map[string]any)
if !ok {
continue
}
if id := jint(crm, "id"); id != nil && *id > 0 {
ids[*id] = true
}
for _, sr := range jarr(crm, "seasonRequests") {
srm, ok := sr.(map[string]any)
if !ok {
continue
}
if id := jint(srm, "childRequestId"); id != nil && *id > 0 {
ids[*id] = true
}
}
}
return ids
}
// tvParentProviderIDs collects a parent record's provider ids as a
// string set so both int requestId and string mediaId keys compare.
func tvParentProviderIDs(p map[string]any) map[string]bool {
ids := map[string]bool{}
for _, k := range []string{"tvDbId", "externalProviderId"} {
if s, ok := toStr(p[k]); ok && s != "" && s != "0" {
ids[s] = true
}
}
return ids
}
// tvParentMatch reports how parent record p matches key: tvMatchChild
// when key.requestID is one of the parent's embedded child request ids
// (authoritative), tvMatchProvider when requestID/mediaID equals a
// parent provider id (fallback — covers versions that put the provider
// id in requestId directly or omit embedded children).
func tvParentMatch(p map[string]any, k tvRecentKey) int {
if k.requestID > 0 && tvParentChildIDs(p)[k.requestID] {
return tvMatchChild
}
provs := tvParentProviderIDs(p)
if k.requestID > 0 && provs[strconv.Itoa(k.requestID)] {
return tvMatchProvider
}
if k.mediaID != "" && provs[k.mediaID] {
return tvMatchProvider
}
return tvMatchNone
}
// resolveRecentTVParents maps recentlyRequested TV rows onto their
// parent request records via one bounded parent scan. Child-id matches
// win over provider-id candidates so an unrelated parent's provider id
// can never shadow a real child id found later in the scan. Returns
// row-index → parent record; scan failures degrade to a warning, never
// an error, since the recent payload itself succeeded.
func (o *op) resolveRecentTVParents(keys []tvRecentKey) map[int]map[string]any {
resolved := map[int]map[string]any{}
candidate := map[int]map[string]any{}
truncated, fail := o.eachTVRequestParent(func(p map[string]any) bool {
for i, k := range keys {
if _, done := resolved[i]; done {
continue
}
switch tvParentMatch(p, k) {
case tvMatchChild:
resolved[i] = p
delete(candidate, i)
case tvMatchProvider:
if _, ok := candidate[i]; !ok {
candidate[i] = p
}
}
}
return len(resolved) == len(keys)
})
for i, p := range candidate {
if _, ok := resolved[i]; !ok {
resolved[i] = p
}
}
switch {
case fail != nil:
o.warnf("tv parent lookup failed; recent tv targets left unresolved")
case truncated:
o.warnf("tv parent lookup truncated; some recent tv targets may be unresolved")
}
return resolved
}
// mergeTVRequestState attempts to match and apply request state from a parent record p.
// Returns true if the parent matched (stopping the scan).
func mergeTVRequestState(it *Media, p map[string]any, tvdbID int, imdbID string) bool {
match := false
if jint(p, "tvDbId") != nil && *jint(p, "tvDbId") == tvdbID {
match = true
} else if imdbID != "" && jstr(p, "imdbId") == imdbID {
match = true
}
if !match {
return false
}
bTrue := true
it.Requested = &bTrue
it.RequestTargets = []OutTarget{{Kind: "tv_parent", ID: *jint(p, "id")}}
// Overlay season/episode states
childRequests := jarr(p, "childRequests")
for _, cr := range childRequests {
crm, ok := cr.(map[string]any)
if !ok {
continue
}
seasonRequests := jarr(crm, "seasonRequests")
for _, sr := range seasonRequests {
srm, ok := sr.(map[string]any)
if !ok {
continue
}
sNum := jint(srm, "seasonNumber")
if sNum == nil {
continue
}
// Find matching season in 'it'
var season *SeasonOut
for i := range it.Seasons {
if it.Seasons[i].SeasonNumber == *sNum {
season = &it.Seasons[i]
break
}
}
if season == nil {
continue
}
episodes := jarr(srm, "episodes")
for _, ep := range episodes {
epm, ok := ep.(map[string]any)
if !ok {
continue
}
epNum := jint(epm, "episodeNumber")
if epNum == nil {
continue
}
// Find matching episode
for i := range season.Episodes {
if season.Episodes[i].EpisodeNumber == *epNum {
season.Episodes[i].Requested = &bTrue
if avail := jbool(epm, "available"); avail != nil && *avail {
season.Episodes[i].Available = &bTrue
}
break
}
}
}
}
}
return true
}
func (o *op) overlayTVRequestState(it *Media, tvdbID int, imdbID string) {
truncated, fail := o.eachTVRequestParent(func(p map[string]any) bool {
return mergeTVRequestState(it, p, tvdbID, imdbID)
})
if fail != nil || truncated {
o.warnf("request state scan incomplete: degraded upstream flags kept")
}
}
+11 -24
View File
@@ -117,6 +117,11 @@
"type": "integer",
"minimum": 1
},
"person_name": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"media": {
"type": "string",
"enum": [
@@ -131,6 +136,7 @@
"required": [
"action",
"person_id",
"person_name",
"media"
],
"additionalProperties": false
@@ -167,7 +173,7 @@
"properties": {
"release_year": {
"type": "integer",
"minimum": 1870,
"minimum": 1901,
"maximum": 9999
},
"decade": {
@@ -205,16 +211,6 @@
"minItems": 1,
"maxItems": 100,
"uniqueItems": true
},
"company_ids": {
"type": "array",
"items": {
"type": "integer",
"minimum": 1
},
"minItems": 1,
"maxItems": 100,
"uniqueItems": true
}
},
"required": [],
@@ -1800,18 +1796,6 @@
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"const": "news"
}
},
"required": [
"action"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
@@ -1852,7 +1836,9 @@
}
},
"required": [
"action"
"action",
"from",
"to"
],
"additionalProperties": false
},
@@ -1864,6 +1850,7 @@
},
"expression": {
"type": "string",
"description": "Quartz cron expression — 6 or 7 fields (seconds minutes hours day-of-month month day-of-week [year]); one of the two day fields must be ?",
"minLength": 1,
"maxLength": 200
}
+3 -1
View File
@@ -113,8 +113,10 @@ func (o *op) searchMulti(a *SearchArgs) *ToolResult {
// projectMultiResult maps MultiSearchResult {id, mediaType, title,
// poster, overview} — the id retains its source/provider namespace.
// mediaType is matched case-insensitively: upstream music results
// emit "Artist".
func (o *op) projectMultiResult(m map[string]any) Media {
mt := jstr(m, "mediaType")
mt := strings.ToLower(jstr(m, "mediaType"))
out := Media{Identifiers: []Identifier{},
Title: jstr(m, "title"), Overview: jstr(m, "overview")}
switch mt {
+13 -27
View File
@@ -8,7 +8,7 @@ import (
"ombi-mcp/internal/ombi"
)
// read_server — status, version, features, news, stats and the
// read_server — status, version, features, stats and the
// administrator-gated cron validation. Families: metrics,
// reference_page.
func handleServer(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
@@ -28,8 +28,6 @@ func handleServer(ctx context.Context, env *Env, raw json.RawMessage) *ToolResul
return o.serverUpdateInfo()
case "update_check":
return o.serverUpdateCheck()
case "news":
return o.serverNews()
case "landing":
return o.serverLanding()
case "features":
@@ -137,20 +135,6 @@ func (o *op) serverUpdateCheck() *ToolResult {
Values: []Metric{metric("update_available", v, "instance", "")}})
}
func (o *op) serverNews() *ToolResult {
raw, fail := o.call("GET", "/api/v2/System/news", nil, nil)
if fail != nil {
return fail
}
items, fail := o.refArray(raw,
[]string{"id"}, []string{"title", "name", "headline"}, "news")
if fail != nil {
return fail
}
return o.ok(&ReferencePage{Kind: "reference_page",
Items: items, Page: singlePage(len(items), "references")})
}
func (o *op) serverLanding() *ToolResult {
raw, fail := o.call("GET", "/api/v1/LandingPage", nil, nil)
if fail != nil {
@@ -196,8 +180,14 @@ func (o *op) serverFeatures() *ToolResult {
}
func (o *op) serverStats(a *ServerArgs) *ToolResult {
// from <= to must be enforced by the server per the contract.
if a.From != "" && a.To != "" {
if !nonempty(a.From) || !nonempty(a.To) {
field := "from"
if nonempty(a.From) {
field = "to"
}
return o.invalid(field, "stats requires both from and to as RFC3339 date-time values")
}
// from <= to is enforced before the upstream call.
f, ferr := time.Parse(time.RFC3339, a.From)
t, terr := time.Parse(time.RFC3339, a.To)
if ferr != nil || terr != nil {
@@ -206,14 +196,7 @@ func (o *op) serverStats(a *ServerArgs) *ToolResult {
if f.After(t) {
return o.invalid("from", "from must be on or before to")
}
}
q := map[string]string{}
if a.From != "" {
q["from"] = a.From
}
if a.To != "" {
q["to"] = a.To
}
q := map[string]string{"from": a.From, "to": a.To}
raw, fail := o.call("GET", "/api/v1/Stats", q, nil)
if fail != nil {
return fail
@@ -256,5 +239,8 @@ func (o *op) serverCronValidate(a *ServerArgs) *ToolResult {
if s := jstr(m, "message"); s != "" {
vals = append(vals, metric("message", s, "instance", ""))
}
if valid, ok := m["success"].(bool); ok && !valid {
o.warnf("Ombi validates Quartz cron syntax (6-7 fields: seconds minutes hours day-of-month month day-of-week [year]); use ? for the unused day-of-month/day-of-week field")
}
return o.ok(&Metrics{Kind: "metrics", Values: vals})
}
+31 -3
View File
@@ -142,9 +142,37 @@ func revisionOf(raw []byte) string {
return hex.EncodeToString(sum[:16])
}
func isDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// serverIdentityLeaf reports whether key is a server record identity
// field directly under /servers/<digits>.
func serverIdentityLeaf(parentPath, key string) bool {
switch key {
case "id", "serverId", "machineIdentifier":
default:
return false
}
rest, ok := strings.CutPrefix(parentPath, "/servers/")
if !ok {
return false
}
return isDigits(rest)
}
// flattenSettings walks a decoded settings document emitting one
// scalar leaf per Change with escaped-JSON-Pointer-style names.
// Excluded and secret-looking fields land in omitted, never values.
// Excluded and secret-looking fields land in omitted, never values,
// with a scoped exemption for server identity fields.
func flattenSettings(v any, path string, out *[]Change, omitted *[]string) {
switch t := v.(type) {
case map[string]any:
@@ -155,7 +183,7 @@ func flattenSettings(v any, path string, out *[]Change, omitted *[]string) {
sort.Strings(keys)
for _, k := range keys {
child := path + "/" + escapePointer(k)
if excludedSettingsFields[k] || secretish(k) {
if (excludedSettingsFields[k] || secretish(k)) && !serverIdentityLeaf(path, k) {
*omitted = append(*omitted, child)
continue
}
@@ -165,7 +193,7 @@ func flattenSettings(v any, path string, out *[]Change, omitted *[]string) {
for i, e := range t {
flattenSettings(e, fmt.Sprintf("%s/%d", path, i), out, omitted)
}
case string, float64, bool:
case string, float64, bool, int, int64:
name := path
if name == "" {
name = "/"