Author SHA1 Message Date
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
25 changed files with 2047 additions and 174 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_votes` | Global vote list or votes on a request. |
| `read_users` | Self, authorized user lookup, claims, online users, preference read. | | `read_users` | Self, authorized user lookup, claims, online users, preference read. |
| `read_library` | Recent additions, calendar, artwork. | | `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. | | `read_integration` | Saved ARR options and authorized media-server metadata. |
| `write_request_create` | Create one media request or an explicit collection request. | | `write_request_create` | Create one media request or an explicit collection request. |
| `write_request_subscribe` | Subscribe/unsubscribe to a request. | | `write_request_subscribe` | Subscribe/unsubscribe to a request. |
+6 -7
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_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_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_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 | | `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_create` | One media request or explicit collection request | core | mutation |
| `write_request_subscribe` | Subscribe/unsubscribe | 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 | | | | | `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. 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. 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. 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. 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.
@@ -111,8 +111,7 @@ The [input catalogue](03-input-schemas.md) defines every parameter, required fie
`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. `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. `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` uses title and year. `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. 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. `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. 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; `target.id` is `requestId` (never a provider id left in `id`). `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”. `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”.
@@ -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. 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. `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}`. 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}`.
+4 -13
View File
@@ -1494,18 +1494,6 @@ Read server status, feature availability, version, update information or usage s
], ],
"additionalProperties": false "additionalProperties": false
}, },
{
"type": "object",
"properties": {
"action": {
"const": "news"
}
},
"required": [
"action"
],
"additionalProperties": false
},
{ {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1546,7 +1534,9 @@ Read server status, feature availability, version, update information or usage s
} }
}, },
"required": [ "required": [
"action" "action",
"from",
"to"
], ],
"additionalProperties": false "additionalProperties": false
}, },
@@ -1558,6 +1548,7 @@ Read server status, feature availability, version, update information or usage s
}, },
"expression": { "expression": {
"type": "string", "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, "minLength": 1,
"maxLength": 200 "maxLength": 200
} }
+2 -2
View File
@@ -17,13 +17,13 @@ Each per-tool output schema is intentionally a bounded projection, not the recur
| Family | Projection rules | | 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; 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. | | 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. On `recent`, `RecentlyRequestedModel.requestId` is the target id for every kind; a provider-shaped `id` (common on TV) is never the target — provider values go in `identifiers` (`mediaId`, `tvDbId`, `externalProviderId`). Include standard and 4K state separately. Never infer one combined lifecycle status when booleans disagree. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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. | | calendar_page | Known date/title/episode/ID values, no invented time zone or promised date-range filtering. |
+6 -6
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. | | 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) | — | | 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. | | 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. | | 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; | — | | 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. | | 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. |
@@ -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. | | 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. | | 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. | | 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. | | 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. | | 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. | | 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; | — | | 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 | — | | 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 | — | | 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) | — | | 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 | — | | 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 | — | | 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: body unspecified | — | | 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: body unspecified | Vetted opaque ID maps to filename; sanitized bounded local slicing. | | 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. | | 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. | | 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. | | 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. |
+26
View File
@@ -298,3 +298,29 @@ The gated live suite is committed and ready; run it with a sourced `.env`. It ad
- Input/output schema contracts in `docs/schema/` unchanged. - Input/output schema contracts in `docs/schema/` unchanged.
- `go test ./...` output is identical to before the phase (package fully behind the tag). - `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. - 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.
+1 -1
View File
@@ -59,7 +59,7 @@ These types deliberately exclude credentials, connection destinations, internal
## Fields excluded from patches ## 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`. `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`.
+744 -16
View File
@@ -3,11 +3,18 @@
package integration_test package integration_test
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"strings" "strings"
"testing" "testing"
) )
func testLogFileID(name string) string {
sum := sha256.Sum256([]byte(name))
return hex.EncodeToString(sum[:16])
}
// --- protocol surface --- // --- protocol surface ---
func TestHandshakeAndToolList(t *testing.T) { func TestHandshakeAndToolList(t *testing.T) {
@@ -36,6 +43,136 @@ func TestHandshakeAndToolList(t *testing.T) {
} }
} }
func TestReadLogsStringArrayList(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_logs", map[string]any{"action": "list"})
data := requireOK(t, out)
var logs struct {
Kind string `json:"kind"`
Files []struct {
FileID string `json:"file_id"`
Name string `json:"name"`
} `json:"files"`
}
if err := json.Unmarshal(data, &logs); err != nil {
t.Fatalf("logs decode: %v", err)
}
if logs.Kind != "logs" || len(logs.Files) != 2 {
t.Fatalf("unexpected log list: %s", data)
}
for _, f := range logs.Files {
if f.FileID != testLogFileID(f.Name) {
t.Errorf("file ID for %q = %q", f.Name, f.FileID)
}
}
}
func TestReadLogsReadByFileID(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_logs", map[string]any{
"action": "read", "file_id": testLogFileID("ombi-20260919.txt"), "limit": 2,
})
data := requireOK(t, out)
var logs struct {
Kind string `json:"kind"`
Lines []string `json:"lines"`
NextOffset *int `json:"next_offset"`
}
if err := json.Unmarshal(data, &logs); err != nil {
t.Fatalf("logs decode: %v", err)
}
if logs.Kind != "logs" || len(logs.Lines) != 2 || logs.NextOffset != nil {
t.Fatalf("unexpected log read: %s", data)
}
bad := c.callTool(t, "read_logs", map[string]any{
"action": "read", "file_id": "does-not-exist",
})
requireErr(t, bad, "NOT_FOUND")
}
func TestReadServerNewsRemoved(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
requireErr(t, c.callTool(t, "read_server", map[string]any{"action": "news"}), "INVALID_ARGUMENT")
}
func TestReadServerStatsRequiresRange(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
err := requireErr(t, c.callTool(t, "read_server", map[string]any{"action": "stats"}), "INVALID_ARGUMENT")
if err.Field != "from" || mock.countCalls("GET", "/api/v1/Stats") != 0 {
t.Fatalf("bare stats error/calls = %+v/%d", err, mock.countCalls("GET", "/api/v1/Stats"))
}
err = requireErr(t, c.callTool(t, "read_server", map[string]any{
"action": "stats", "from": "2026-09-01T00:00:00Z",
}), "INVALID_ARGUMENT")
if err.Field != "to" {
t.Errorf("field = %q, want to", err.Field)
}
requireErr(t, c.callTool(t, "read_server", map[string]any{
"action": "stats", "from": "2026-09-02T00:00:00Z", "to": "2026-09-01T00:00:00Z",
}), "INVALID_ARGUMENT")
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"`
Values []any `json:"values"`
}
if err := json.Unmarshal(data, &metrics); err != nil {
t.Fatalf("metrics decode: %v", err)
}
if metrics.Kind != "metrics" || len(metrics.Values) != 7 {
t.Fatalf("unexpected stats: %s", data)
}
}
func TestReadServerCronValidateQuartz(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_server", map[string]any{
"action": "cron_validate", "expression": "0 0 0 * * ?",
})
data := requireOK(t, out)
var metrics struct {
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
}
if err := json.Unmarshal(data, &metrics); err != nil || len(metrics.Values) != 1 ||
metrics.Values[0].Name != "valid" || metrics.Values[0].Value != true {
t.Fatalf("Quartz-valid expression reported invalid: %s", data)
}
if got := mock.lastBody(t, "POST", "/api/v1/Settings/testcron")["expression"]; got != "0 0 0 * * ?" {
t.Errorf("wire expression = %#v", got)
}
out = c.callTool(t, "read_server", map[string]any{
"action": "cron_validate", "expression": "0 0 * * *",
})
data = requireOK(t, out)
if err := json.Unmarshal(data, &metrics); err != nil || len(metrics.Values) < 1 || metrics.Values[0].Value != false {
t.Fatalf("Quartz-invalid expression reported valid: %s", data)
}
if !strings.Contains(strings.Join(out.Envelope.Warnings, " "), "Quartz cron syntax") {
t.Errorf("missing Quartz warning: %v", out.Envelope.Warnings)
}
}
func TestBundleRestriction(t *testing.T) { func TestBundleRestriction(t *testing.T) {
mock := newMockOmbi(t, "jwt") mock := newMockOmbi(t, "jwt")
env := mock.env() env := mock.env()
@@ -218,6 +355,53 @@ func TestReadRequestsTVChildrenAreChildKind(t *testing.T) {
} }
} }
func TestReadRequestsChildrenSurfacesParentProviderIDs(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "children", "parent_request_id": 12,
})
data := requireOK(t, out)
var page struct {
Items []struct {
Target struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"target"`
ParentRequestID *int `json:"parent_request_id"`
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) != 1 {
t.Fatalf("expected 1 child, got %d: %s", len(page.Items), data)
}
it := page.Items[0]
// The child id is the real upstream child PK — provider-shaped for
// first-request children by design — and stays callable.
if it.Target.Kind != "tv_child" || it.Target.ID != 77 {
t.Errorf("child target = %+v, want tv_child 77", it.Target)
}
if it.ParentRequestID == nil || *it.ParentRequestID != 12 {
t.Errorf("parent_request_id = %v, want 12", it.ParentRequestID)
}
ids := map[string]string{}
for _, id := range it.Identifiers {
ids[id.Namespace] = id.Value
}
if ids["tvdb"] != "81189" || ids["tmdb"] != "1396" || ids["imdb"] != "tt0903747" {
t.Errorf("parentRequest provider ids not surfaced: %v", ids)
}
assertNoLeak(t, out.Raw)
}
// --- TV identifier namespaces (Gitea issue #1) --- // --- TV identifier namespaces (Gitea issue #1) ---
func TestReadMediaDetailsTVDBUsesV1InfoRoute(t *testing.T) { func TestReadMediaDetailsTVDBUsesV1InfoRoute(t *testing.T) {
@@ -512,6 +696,10 @@ func TestReadDiscoverRequestedFallsBackToRecentFeed(t *testing.T) {
Namespace string `json:"namespace"` Namespace string `json:"namespace"`
Value string `json:"value"` Value string `json:"value"`
} `json:"identifiers"` } `json:"identifiers"`
RequestTargets []struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"request_targets"`
} `json:"items"` } `json:"items"`
Page struct { Page struct {
Mode string `json:"mode"` Mode string `json:"mode"`
@@ -535,6 +723,14 @@ func TestReadDiscoverRequestedFallsBackToRecentFeed(t *testing.T) {
if ids["tmdb"] == "" { if ids["tmdb"] == "" {
t.Errorf("%s fallback missing tmdb identifier: %s", media, data) t.Errorf("%s fallback missing tmdb identifier: %s", media, data)
} }
if media == "tv" {
// Recent TV rows carry child request ids; the fallback must
// resolve them to the parent target like the primary path.
rt := page.Items[0].RequestTargets
if len(rt) != 1 || rt[0].Kind != "tv_parent" || rt[0].ID != 909 {
t.Errorf("tv fallback request_targets = %+v, want [{tv_parent 909}]", rt)
}
}
assertNoLeak(t, out.Raw) assertNoLeak(t, out.Raw)
} }
if n := mock.countCalls("GET", "/api/v2/Requests/recentlyRequested"); n != 2 { if n := mock.countCalls("GET", "/api/v2/Requests/recentlyRequested"); n != 2 {
@@ -646,7 +842,7 @@ func TestReadRequestsRecentTVParentUsesRequestID(t *testing.T) {
if err := json.Unmarshal(data, &page); err != nil { if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data) t.Fatalf("decode: %v\n%s", err, data)
} }
var movie, tv *struct { items := map[string]*struct {
Target struct { Target struct {
Kind string `json:"kind"` Kind string `json:"kind"`
ID int `json:"id"` ID int `json:"id"`
@@ -656,35 +852,50 @@ func TestReadRequestsRecentTVParentUsesRequestID(t *testing.T) {
Namespace string `json:"namespace"` Namespace string `json:"namespace"`
Value string `json:"value"` Value string `json:"value"`
} `json:"identifiers"` } `json:"identifiers"`
} }{}
for i := range page.Items { for i := range page.Items {
switch page.Items[i].Title { it := page.Items[i]
case "Yummy": items[it.Title] = &it
movie = &page.Items[i]
case "Seven Up!":
tv = &page.Items[i]
} }
} movie, tv, tvChildID, orphan := items["Yummy"], items["Seven Up!"],
if movie == nil || tv == nil { items["trigger-500 and something"], items["Provider Only"]
if movie == nil || tv == nil || tvChildID == nil || orphan == nil {
t.Fatalf("missing recent items: %s", data) t.Fatalf("missing recent items: %s", data)
} }
if movie.Target.Kind != "movie" || movie.Target.ID != 2207 { if movie.Target.Kind != "movie" || movie.Target.ID != 2207 {
t.Errorf("movie target = %+v", movie.Target) t.Errorf("movie target = %+v", movie.Target)
} }
if tv.Target.Kind != "tv_parent" || tv.Target.ID != 88 { // TV requestId 259032 is a provider-shaped child PK upstream; the
t.Errorf("tv_parent target = %+v, want request id 88 not provider 259032", tv.Target) // parent scan must resolve it to the real parent request id 909.
if tv.Target.Kind != "tv_parent" || tv.Target.ID != 909 {
t.Errorf("tv_parent target = %+v, want resolved parent id 909 not child/provider 259032", tv.Target)
} }
tvIDs := map[string]string{} tvIDs := map[string]string{}
for _, id := range tv.Identifiers { for _, id := range tv.Identifiers {
tvIDs[id.Namespace] = id.Value tvIDs[id.Namespace] = id.Value
} }
if tvIDs["tmdb"] != "259032" { if tvIDs["tvdb"] != "259032" || tvIDs["tmdb"] != "118680" || tvIDs["imdb"] != "tt1439629" {
t.Errorf("tv provider id missing from identifiers: %v", tvIDs) t.Errorf("tv identifiers missing parent provider ids: %v", tvIDs)
} }
for _, it := range page.Items { if tvIDs["ombi_tv_child"] != "259032" {
if it.Title == "Provider Only" && it.Target.ID == 111 { t.Errorf("child request id missing from identifiers: %v", tvIDs)
t.Errorf("provider id occupied target.id on requestId-less recent item: %+v", it.Target)
} }
// A real (non-provider-shaped) child id resolves via the parent's
// embedded childRequests[].id alone.
if tvChildID.Target.Kind != "tv_parent" || tvChildID.Target.ID != 42 {
t.Errorf("child-id recent item target = %+v, want parent id 42", tvChildID.Target)
}
// Unresolvable rows keep id 0 and a provider-shaped `id` must
// never occupy the target.
if orphan.Target.Kind != "tv_parent" || orphan.Target.ID != 0 {
t.Errorf("unresolvable tv item target = %+v, want tv_parent id 0", orphan.Target)
}
if len(out.Envelope.Warnings) == 0 {
t.Errorf("expected a warning for the unresolvable tv item: %s", out.Raw)
}
// All TV rows resolve in a single bounded parent scan.
if n := mock.countCalls("GET", "/api/v1/Request/tv/"); n != 1 {
t.Errorf("tv parent scan calls = %d, want 1", n)
} }
assertNoLeak(t, out.Raw) assertNoLeak(t, out.Raw)
} }
@@ -1472,3 +1683,520 @@ func TestAPIKeyMode(t *testing.T) {
t.Errorf("ApiKey header never reached upstream") t.Errorf("ApiKey header never reached upstream")
} }
} }
// --- M5 request state and list routes ---
func TestM5TVDBDetailsRequestStateOverlay(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "tv", "provider": "tvdb", "id": 81189},
})
data := requireOK(t, out)
var m struct {
Items []struct {
Requested *bool `json:"requested"`
RequestTargets []struct {
Kind string `json:"kind"`
ID int `json:"id"`
} `json:"request_targets"`
Seasons []struct {
SeasonNumber int `json:"season_number"`
Episodes []struct {
EpisodeNumber int `json:"episode_number"`
Requested *bool `json:"requested"`
Available *bool `json:"available"`
} `json:"episodes"`
} `json:"seasons"`
} `json:"items"`
}
json.Unmarshal(data, &m)
if len(m.Items) != 1 {
t.Fatalf("expected 1 item, got %d", len(m.Items))
}
it := m.Items[0]
if it.Requested == nil || !*it.Requested {
t.Errorf("expected Requested=true from overlay")
}
if len(it.RequestTargets) != 1 || it.RequestTargets[0].Kind != "tv_parent" || it.RequestTargets[0].ID != 42 {
t.Errorf("expected RequestTargets=[{tv_parent 42}], got %v", it.RequestTargets)
}
var ep1Requested, ep1Available bool
for _, s := range it.Seasons {
if s.SeasonNumber == 1 {
for _, e := range s.Episodes {
if e.EpisodeNumber == 1 {
if e.Requested != nil {
ep1Requested = *e.Requested
}
if e.Available != nil {
ep1Available = *e.Available
}
}
}
}
}
if !ep1Requested {
t.Errorf("expected season 1 episode 1 Requested=true")
}
if !ep1Available {
t.Errorf("expected season 1 episode 1 Available=true")
}
if calls := mock.countCalls("GET", "/api/v1/Search/tv/info/"); calls != 1 {
t.Errorf("expected 1 call to v1 info route, got %d", calls)
}
if calls := mock.countCalls("GET", "/api/v1/Request/tv/"); calls < 1 {
t.Errorf("expected >=1 call to v1 tv parent list route, got %d", calls)
}
if calls := mock.countCalls("GET", "/api/v2/Search/tv/moviedb/"); calls != 0 {
t.Errorf("expected 0 calls to v2 TMDB alias route, got %d", calls)
}
}
func TestM5TVSearchFallback(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_requests", map[string]any{
"action": "search",
"media": "tv",
"query": "trigger-500",
})
data := requireOK(t, out)
var page struct {
Items []struct {
Title string `json:"title"`
} `json:"items"`
}
json.Unmarshal(data, &page)
if len(page.Items) != 1 {
t.Fatalf("expected 1 item from fallback, got %d", len(page.Items))
}
if page.Items[0].Title != "trigger-500 and something" {
t.Errorf("expected item title 'trigger-500 and something', got %q", page.Items[0].Title)
}
}
func TestM5V2List500ErrorSurfaced(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_media", map[string]any{
"action": "details",
"target": map[string]any{"media": "movie", "provider": "tmdb", "id": 500001},
})
if !out.IsError {
t.Fatalf("expected error on 500")
}
if !strings.Contains(out.Text, "System.NullReferenceException") {
t.Errorf("expected error message to contain sanitized detail, got %q", out.Text)
}
}
func TestM6ReferenceDeterministicValueAndMojibake(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
// 1. Genres: value == 53 (float64 in unmarshaled json)
outGenres := c.callTool(t, "read_reference", map[string]any{
"action": "genres",
"media": "movie",
})
dataGenres := requireOK(t, outGenres)
var pageGenres struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(dataGenres, &pageGenres); err != nil {
t.Fatalf("unmarshal genres: %v", err)
}
if len(pageGenres.Items) != 1 || pageGenres.Items[0].ID != "53" || pageGenres.Items[0].Name != "Thriller" || pageGenres.Items[0].Value != float64(53) {
t.Errorf("unexpected genres item: %+v", pageGenres.Items)
}
// 2. Profiles (options): value == 6 (not weight 7, not enabled false)
outProfiles := c.callTool(t, "read_integration", map[string]any{
"action": "options",
"service": "radarr",
"resource": "profiles",
})
dataProfiles := requireOK(t, outProfiles)
var pageProfiles struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(dataProfiles, &pageProfiles); err != nil {
t.Fatalf("unmarshal profiles: %v", err)
}
if len(pageProfiles.Items) != 1 || pageProfiles.Items[0].ID != "6" || pageProfiles.Items[0].Name != "HD" || pageProfiles.Items[0].Value != float64(6) {
t.Errorf("unexpected profiles item: %+v", pageProfiles.Items)
}
// 3. Watch providers: value == 8 (not provider_name, not logo_path)
outWP := c.callTool(t, "read_reference", map[string]any{
"action": "watch_providers",
"media": "movie",
})
dataWP := requireOK(t, outWP)
var pageWP struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(dataWP, &pageWP); err != nil {
t.Fatalf("unmarshal watch providers: %v", err)
}
if len(pageWP.Items) != 1 || pageWP.Items[0].ID != "8" || pageWP.Items[0].Name != "Netflix" || pageWP.Items[0].Value != float64(8) {
t.Errorf("unexpected watch provider item: %+v", pageWP.Items)
}
// 4. Languages: mojibake skip (ky -> Кыргызча) + all-corrupted warning (zz -> ?????? + warning)
outLang := c.callTool(t, "read_reference", map[string]any{
"action": "languages",
})
dataLang := requireOK(t, outLang)
var pageLang struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(dataLang, &pageLang); err != nil {
t.Fatalf("unmarshal languages: %v", err)
}
if len(pageLang.Items) != 2 {
t.Fatalf("expected 2 language items, got %d", len(pageLang.Items))
}
if pageLang.Items[0].ID != "ky" || pageLang.Items[0].Name != "Кыргызча" {
t.Errorf("expected ky to have name 'Кыргызча', got %+v", pageLang.Items[0])
}
if pageLang.Items[1].ID != "zz" || pageLang.Items[1].Name != "??????" {
t.Errorf("expected zz to have name '??????', got %+v", pageLang.Items[1])
}
hasWarn := false
for _, w := range outLang.Envelope.Warnings {
if strings.Contains(w, "upstream language label appears corrupted") && strings.Contains(w, "zz") {
hasWarn = true
break
}
}
if !hasWarn {
t.Errorf("expected corrupted label warning for zz in warnings: %v", outLang.Envelope.Warnings)
}
}
func TestM6IntegrationRootFolders(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_integration", map[string]any{
"action": "options",
"service": "sonarr",
"resource": "root_folders",
})
data := requireOK(t, out)
var page struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
Category string `json:"category"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("unmarshal root_folders: %v", err)
}
if len(page.Items) != 1 {
t.Fatalf("expected 1 root folder, got %d", len(page.Items))
}
rf := page.Items[0]
if rf.ID != "21" || rf.Name != "/media/tv" || rf.Value != float64(21) || rf.Category != "root_folder" {
t.Errorf("unexpected root_folder projection: %+v", rf)
}
}
func TestM6PlexServersAndLibraries(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
// 1. Plex servers decoded from object wrapper {"success":true,"servers":[...]}
outServers := c.callTool(t, "read_integration", map[string]any{
"action": "plex",
"resource": "servers",
})
dataServers := requireOK(t, outServers)
var pageServers struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
Category string `json:"category"`
} `json:"items"`
}
if err := json.Unmarshal(dataServers, &pageServers); err != nil {
t.Fatalf("unmarshal servers: %v", err)
}
if len(pageServers.Items) != 1 {
t.Fatalf("expected 1 plex server, got %d", len(pageServers.Items))
}
srv := pageServers.Items[0]
if srv.ID != "mach-plex-1" || srv.Name != "Main Plex" || srv.Value != float64(7) || srv.Category != "plex_server" {
t.Errorf("unexpected plex server projection: %+v", srv)
}
// 2. Plex libraries round-trip using machine_id discovered from servers
outLibs := c.callTool(t, "read_integration", map[string]any{
"action": "plex_libraries",
"machine_id": srv.ID,
})
dataLibs := requireOK(t, outLibs)
var pageLibs struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
Category string `json:"category"`
} `json:"items"`
}
if err := json.Unmarshal(dataLibs, &pageLibs); err != nil {
t.Fatalf("unmarshal libraries: %v", err)
}
if len(pageLibs.Items) != 2 {
t.Fatalf("expected 2 libraries, got %d", len(pageLibs.Items))
}
if pageLibs.Items[0].ID != "3" || pageLibs.Items[0].Name != "TV Shows" || pageLibs.Items[0].Value != "3" {
t.Errorf("unexpected library item 0: %+v", pageLibs.Items[0])
}
if pageLibs.Items[1].ID != "1" || pageLibs.Items[1].Name != "Movies" || pageLibs.Items[1].Value != "1" {
t.Errorf("unexpected library item 1: %+v", pageLibs.Items[1])
}
// 3. Plex libraries with broken machineId surfaces UPSTREAM_REJECTED + message
outBroken := c.callTool(t, "read_integration", map[string]any{
"action": "plex_libraries",
"machine_id": "broken",
})
errBroken := requireErr(t, outBroken, "UPSTREAM_REJECTED")
if !strings.Contains(errBroken.Message, "could not reach server") {
t.Errorf("expected rejection message to contain 'could not reach server', got %q", errBroken.Message)
}
}
func TestM6PlexFriendsAndWatchlistUsers(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
// Friends -> user_page
outFriends := c.callTool(t, "read_integration", map[string]any{
"action": "plex",
"resource": "friends",
})
dataFriends := requireOK(t, outFriends)
var pageFriends struct {
Kind string `json:"kind"`
Items []struct {
ID string `json:"id"`
UserName string `json:"user_name"`
} `json:"items"`
}
if err := json.Unmarshal(dataFriends, &pageFriends); err != nil {
t.Fatalf("unmarshal friends: %v", err)
}
if pageFriends.Kind != "user_page" || len(pageFriends.Items) != 1 || pageFriends.Items[0].UserName != "plexfriend" {
t.Errorf("unexpected friends: %+v", pageFriends)
}
// Watchlist users -> user_page
outWL := c.callTool(t, "read_integration", map[string]any{
"action": "plex",
"resource": "watchlist_users",
})
dataWL := requireOK(t, outWL)
var pageWL struct {
Kind string `json:"kind"`
Items []struct {
ID string `json:"id"`
UserName string `json:"user_name"`
} `json:"items"`
}
if err := json.Unmarshal(dataWL, &pageWL); err != nil {
t.Fatalf("unmarshal watchlist_users: %v", err)
}
if pageWL.Kind != "user_page" || len(pageWL.Items) != 1 || pageWL.Items[0].UserName != "wl" {
t.Errorf("unexpected watchlist_users: %+v", pageWL)
}
}
func TestM6ServerIdentityDiscoveryAndMediaServer(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
// 1. read_settings emby: server identity leaves exposed, secrets/top-level id omitted
outSettings := c.callTool(t, "read_settings", map[string]any{
"section": "emby",
})
dataSettings := requireOK(t, outSettings)
var s struct {
Kind string `json:"kind"`
Section string `json:"section"`
Revision string `json:"revision"`
Values []struct {
Name string `json:"name"`
Value any `json:"value"`
} `json:"values"`
OmittedFields []string `json:"omitted_fields"`
}
if err := json.Unmarshal(dataSettings, &s); err != nil {
t.Fatalf("settings decode: %v", err)
}
valMap := map[string]any{}
for _, v := range s.Values {
valMap[v.Name] = v.Value
}
omittedSet := map[string]bool{}
for _, o := range s.OmittedFields {
omittedSet[o] = true
}
if valMap["/servers/0/id"] != float64(3) {
t.Errorf("expected /servers/0/id == 3, got %v", valMap["/servers/0/id"])
}
if valMap["/servers/0/serverId"] != "emby-guid" {
t.Errorf("expected /servers/0/serverId == 'emby-guid', got %v", valMap["/servers/0/serverId"])
}
if valMap["/servers/0/name"] != "Emby One" {
t.Errorf("expected /servers/0/name == 'Emby One', got %v", valMap["/servers/0/name"])
}
if _, ok := valMap["/id"]; ok {
t.Errorf("top-level /id must NOT be present in values")
}
if _, ok := valMap["/servers/0/apiKey"]; ok {
t.Errorf("apiKey must NOT be present in values")
}
if _, ok := valMap["/servers/0/ip"]; ok {
t.Errorf("ip must NOT be present in values")
}
if !omittedSet["/id"] {
t.Errorf("expected /id in omitted_fields")
}
if !omittedSet["/servers/0/apiKey"] {
t.Errorf("expected /servers/0/apiKey in omitted_fields")
}
if !omittedSet["/servers/0/ip"] {
t.Errorf("expected /servers/0/ip in omitted_fields")
}
if omittedSet["/servers/0/id"] {
t.Errorf("/servers/0/id must NOT be in omitted_fields")
}
if omittedSet["/servers/0/serverId"] {
t.Errorf("/servers/0/serverId must NOT be in omitted_fields")
}
// 2. Patch rejection: attempting to patch /servers/0/id must return INVALID_ARGUMENT
outPatch := c.callTool(t, "write_settings_patch", map[string]any{
"section": "emby",
"revision": s.Revision,
"changes": map[string]any{
"servers": []any{
map[string]any{"id": 999},
},
},
})
requireErr(t, outPatch, "INVALID_ARGUMENT")
// 3. media_server emby info round-trip using discovered serverId "emby-guid"
outInfo := c.callTool(t, "read_integration", map[string]any{
"action": "media_server",
"service": "emby",
"resource": "info",
"server_id": "emby-guid",
})
dataInfo := requireOK(t, outInfo)
var pageInfo struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(dataInfo, &pageInfo); err != nil {
t.Fatalf("unmarshal emby info: %v", err)
}
if len(pageInfo.Items) != 1 || pageInfo.Items[0].ID != "emby-guid" || pageInfo.Items[0].Name != "Emby One" {
t.Errorf("unexpected emby info item: %+v", pageInfo.Items)
}
assertNoLeak(t, outInfo.Raw)
// 4. media_server emby libraries round-trip using discovered numeric id "3"
outLibs := c.callTool(t, "read_integration", map[string]any{
"action": "media_server",
"service": "emby",
"resource": "libraries",
"server_id": "3",
})
dataLibs := requireOK(t, outLibs)
var pageLibs struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Value any `json:"value"`
} `json:"items"`
}
if err := json.Unmarshal(dataLibs, &pageLibs); err != nil {
t.Fatalf("unmarshal emby libraries: %v", err)
}
if len(pageLibs.Items) != 1 || pageLibs.Items[0].ID != "f137" || pageLibs.Items[0].Name != "Movies" {
t.Errorf("unexpected emby library item: %+v", pageLibs.Items)
}
assertNoLeak(t, outLibs.Raw)
// Assert POST bodies to Emby info and Library carried the saved server object
reqs := mock.requests()
infoFound := false
libFound := false
for _, r := range reqs {
if r.Method == "POST" && r.Path == "/api/v1/Emby/info" {
infoFound = true
if !strings.Contains(string(r.Body), `"apiKey":"SECRET"`) || !strings.Contains(string(r.Body), `"emby-guid"`) {
t.Errorf("POST /api/v1/Emby/info did not carry saved server object: %s", r.Body)
}
}
if r.Method == "POST" && r.Path == "/api/v1/Emby/Library" {
libFound = true
if !strings.Contains(string(r.Body), `"apiKey":"SECRET"`) || !strings.Contains(string(r.Body), `"emby-guid"`) {
t.Errorf("POST /api/v1/Emby/Library did not carry saved server object: %s", r.Body)
}
}
}
if !infoFound {
t.Errorf("POST /api/v1/Emby/info was not called")
}
if !libFound {
t.Errorf("POST /api/v1/Emby/Library was not called")
}
}
+211
View File
@@ -4,6 +4,7 @@ package integration_test
import ( import (
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -70,6 +71,50 @@ func TestLiveServerStatus(t *testing.T) {
assertNoLeak(t, out.Raw) 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 --- // --- read/projection contract against real payloads ---
func TestLiveReadRequestsList(t *testing.T) { func TestLiveReadRequestsList(t *testing.T) {
@@ -476,6 +521,54 @@ func TestLiveDiscoverTVBrowseHasIdentifiers(t *testing.T) {
assertNoLeak(t, out.Raw) 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) { func TestLiveSearchMultiArtistMapped(t *testing.T) {
c := liveServer(t) c := liveServer(t)
out := c.callTool(t, "read_search", map[string]any{ out := c.callTool(t, "read_search", map[string]any{
@@ -509,3 +602,121 @@ func TestLiveSearchMultiArtistMapped(t *testing.T) {
} }
assertNoLeak(t, out.Raw) 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)
}
}
}
+192 -8
View File
@@ -53,12 +53,21 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
m.settings = map[string]map[string]any{ m.settings = map[string]map[string]any{
"/api/v1/Settings/customization": mockCustomization(), "/api/v1/Settings/customization": mockCustomization(),
"/api/v1/Settings/notifications/discord": mockDiscordSettings(), "/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 := http.NewServeMux()
mux.HandleFunc("POST /api/v1/Token", m.handleToken) 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", m.wrap(m.fixed(`200`)))
mux.HandleFunc("GET /api/v1/Status/info", m.wrap(m.fixed(`"mock-status-info"`))) 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/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/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/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/{id}", m.wrap(m.movieDetails))
@@ -89,6 +98,15 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
mux.HandleFunc("POST /api/v1/Settings/customization", m.wrap(m.settingsPost)) 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("GET /api/v1/Settings/notifications/discord", m.wrap(m.settingsGet))
mux.HandleFunc("POST /api/v1/Settings/notifications/discord", m.wrap(m.settingsPost)) 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/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/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`))) mux.HandleFunc("GET /api/v1/Request/movie/total", m.wrap(m.fixed(`7`)))
@@ -97,6 +115,15 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
mockUser("u-1", "alice"), mockUser("u-2", "bob"), 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/Identity/User/{id}", m.wrap(m.userGet))
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 mux.HandleFunc("/api/v1/", m.wrap(m.catchAll)) // fallthrough: 404
m.Server = httptest.NewServer(mux) m.Server = httptest.NewServer(mux)
t.Cleanup(m.Server.Close) t.Cleanup(m.Server.Close)
@@ -358,7 +385,7 @@ func mockTVDetail() map[string]any {
map[string]any{ map[string]any{
"seasonNumber": 1, "seasonNumber": 1,
"episodes": []any{ "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": 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": 3, "title": "...And the Bag's in the River"},
map[string]any{"episodeNumber": 4, "title": "Cancer Man"}, map[string]any{"episodeNumber": 4, "title": "Cancer Man"},
@@ -397,7 +424,8 @@ func mockTVInfoV1() map[string]any {
"title": "Breaking Bad", "name": "Breaking Bad", "title": "Breaking Bad", "name": "Breaking Bad",
"overview": "A chemistry teacher turns to cooking meth.", "overview": "A chemistry teacher turns to cooking meth.",
"firstAired": "2008-01-20T00:00:00", "status": "Ended", "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", "poster": "/ggFHVNu6YYI5L9pCfOacjizRGt.jpg",
"banner": "/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg", "banner": "/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
"genre": []any{"Crime", "Drama", "Thriller"}, "genre": []any{"Crime", "Drama", "Thriller"},
@@ -472,7 +500,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}, "childFieldFuture": []any{1, 2},
} }
} }
@@ -520,6 +553,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 --- // --- handlers ---
func (m *mockOmbi) movieList(w http.ResponseWriter, r *http.Request) { func (m *mockOmbi) movieList(w http.ResponseWriter, r *http.Request) {
@@ -532,6 +583,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) { func (m *mockOmbi) tvList(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ json.NewEncoder(w).Encode(map[string]any{
@@ -552,6 +673,10 @@ func (m *mockOmbi) movieDetails(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html><body><h1>500 Internal Server Error</h1>" + w.Write([]byte("<html><body><h1>500 Internal Server Error</h1>" +
"System.NullReferenceException at Ombi.Core.Engine.MovieRequestEngine" + "System.NullReferenceException at Ombi.Core.Engine.MovieRequestEngine" +
"Authorization: Bearer should-never-appear</body></html>")) "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: default:
m.jsonErr(w, http.StatusNotFound, "Movie not found") m.jsonErr(w, http.StatusNotFound, "Movie not found")
} }
@@ -595,6 +720,13 @@ func (m *mockOmbi) tvInfoTVDB(w http.ResponseWriter, r *http.Request) {
} }
func (m *mockOmbi) tvSearch(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) m.json([]any{mockTVSearchHit()})(w, r)
} }
@@ -672,16 +804,27 @@ func (m *mockOmbi) recentlyRequested(w http.ResponseWriter, r *http.Request) {
"requestDate": "2026-09-01T10:00:00", "requestDate": "2026-09-01T10:00:00",
}, },
map[string]any{ map[string]any{
// Live TV recent items put the provider id in `id` and // Live TV rows carry the CHILD request id in requestId —
// may omit requestId; target.id must not become 259032. // for new-request children upstream persists the provider
"id": 259032, "requestId": 88, "type": 0, // id as the child PK — and the parent's externalProviderId
"title": "Seven Up!", "mediaId": "259032", // 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", "userId": "u-1", "requestDate": "2026-09-02T10:00:00",
}, },
map[string]any{ 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", "id": 111, "type": 0, "title": "Provider Only",
"mediaId": "111", "userId": "u-1", "mediaId": "111", "userId": "u-1",
"requestDate": "2026-09-03T10:00:00", "requestDate": "2026-09-04T10:00:00",
}, },
})(w, r) })(w, r)
} }
@@ -773,6 +916,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 // mockEnv returns the server env pointing at this mock in the
// requested mode, plus all three bundles. // requested mode, plus all three bundles.
func (m *mockOmbi) env() map[string]string { func (m *mockOmbi) env() map[string]string {
+1 -1
View File
@@ -113,7 +113,7 @@ type LibraryArgs struct {
// read_server // read_server
type ServerArgs struct { 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"` From string `json:"from,omitempty"`
To string `json:"to,omitempty"` To string `json:"to,omitempty"`
Expression string `json:"expression,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: default:
if st >= 500 { if st >= 500 {
e.Code, e.Message, e.Retryable = "UPSTREAM_REJECTED", 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 { } else {
e.Code, e.Message, e.Retryable = "UPSTREAM_REJECTED", e.Code, e.Message, e.Retryable = "UPSTREAM_REJECTED",
fmt.Sprintf("upstream returned HTTP %d", st), false fmt.Sprintf("upstream returned HTTP %d", st), false
+22
View File
@@ -111,6 +111,11 @@ func (o *op) discoverRequestedFallback(a *DiscoverArgs, media string) *ToolResul
wantType = 0 wantType = 0
} }
items := make([]Media, 0, len(arr)) items := make([]Media, 0, len(arr))
type pendingTV struct {
idx int
key tvRecentKey
}
pending := []pendingTV{}
for _, m := range arr { for _, m := range arr {
kind, ok := toInt(m["type"]) kind, ok := toInt(m["type"])
if !ok || kind != wantType { if !ok || kind != wantType {
@@ -132,7 +137,12 @@ func (o *op) discoverRequestedFallback(a *DiscoverArgs, media string) *ToolResul
if id, ok := toInt(m["requestId"]); ok && id > 0 { if id, ok := toInt(m["requestId"]); ok && id > 0 {
kind := "movie" kind := "movie"
if media == "tv" { 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" kind = "tv_child"
pending = append(pending, pendingTV{
len(items), tvRecentKey{requestID: id, mediaID: jstr(m, "mediaId")}})
} }
it.RequestTargets = []OutTarget{{Kind: kind, ID: id}} it.RequestTargets = []OutTarget{{Kind: kind, ID: id}}
} }
@@ -143,6 +153,18 @@ func (o *op) discoverRequestedFallback(a *DiscoverArgs, media string) *ToolResul
} }
items = append(items, it) 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") win, _ := localWindow(o, items, a.Page, "media")
o.truncated = true o.truncated = true
o.warnf("requested browse fell back to Ombi's bounded recently-requested feed") o.warnf("requested browse fell back to Ombi's bounded recently-requested feed")
+133 -23
View File
@@ -125,37 +125,105 @@ func (o *op) integrationOptions(a *IntegrationArgs) *ToolResult {
return o.refsOrScalar(raw, cat) 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 // 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 { func (o *op) refsOrScalar(raw []byte, cat string) *ToolResult {
if items, fail := o.refArray(raw, topKeys := refKeysFor(cat, defaultOptionRefKeys)
[]string{"id", "value", "key"}, []string{"name", "label", "value"}, cat); fail == nil { if items, fail := o.refArray(raw, topKeys.id, topKeys.name, cat); fail == nil {
return o.ok(&ReferencePage{Kind: "reference_page", return o.ok(&ReferencePage{Kind: "reference_page",
Items: items, Page: singlePage(len(items), "references")}) Items: items, Page: singlePage(len(items), "references")})
} }
// Non-array bodies: scalar or single object → one reference. if m, fail := o.decodeObject(raw); fail == nil {
if v, fail := o.decodeScalar(raw); fail == nil && v != nil { if rejected, msg := isRejectedObject(m); rejected {
r := Reference{Name: cat, Category: cat} return o.fail("UPSTREAM_REJECTED", msg, false)
switch t := v.(type) {
case string, float64, bool:
r.Value = t
} }
return o.ok(&ReferencePage{Kind: "reference_page", // Nested containers (e.g. CouchPotatoProfiles.list, Plex Libraries data, Media Server items)
Items: []Reference{r}, Page: singlePage(1, "references")}) // 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) nestedKeys := refKeysFor(cat, nestedDefaults)
if fail != nil {
return fail
}
// Nested containers (e.g. CouchPotatoProfiles.list) project
// their first array member list.
for _, v := range m { for _, v := range m {
if arr, ok := v.([]any); ok { if arr, ok := v.([]any); ok {
items := []Reference{} items := []Reference{}
for _, e := range arr { for _, e := range arr {
if em, ok := e.(map[string]any); ok { if em, ok := e.(map[string]any); ok {
items = append(items, refOf(em, items = append(items, o.refOf(em,
[]string{"_id", "id", "value"}, []string{"label", "name"}, nil, cat)) nestedKeys.id, nestedKeys.name, nestedKeys.value, cat))
} }
} }
items = capItems(o, items) items = capItems(o, items)
@@ -163,9 +231,24 @@ func (o *op) refsOrScalar(raw []byte, cat string) *ToolResult {
Items: items, Page: singlePage(len(items), "references")}) 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", return o.ok(&ReferencePage{Kind: "reference_page",
Items: []Reference{r}, Page: singlePage(1, "references")}) 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 { func (o *op) integrationPlex(a *IntegrationArgs) *ToolResult {
@@ -188,12 +271,39 @@ func (o *op) integrationPlex(a *IntegrationArgs) *ToolResult {
} }
// refsOrUsers projects a response that may be an array of user-like // 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 { func (o *op) refsOrUsers(raw []byte, cat string) *ToolResult {
arr, fail := o.decodeArray(raw) arr, fail := o.decodeArray(raw)
if fail != nil { if fail != nil {
m, objFail := o.decodeObject(raw)
if objFail != nil {
return fail 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. // User-shaped records (id+userName/username/email) → user_page.
users := []User{} users := []User{}
refs := []Reference{} refs := []Reference{}
@@ -201,8 +311,8 @@ func (o *op) refsOrUsers(raw []byte, cat string) *ToolResult {
if jstr(m, "userName", "username", "email") != "" { if jstr(m, "userName", "username", "email") != "" {
users = append(users, projectUser(m)) users = append(users, projectUser(m))
} else { } else {
refs = append(refs, refOf(m, refs = append(refs, o.refOf(m,
[]string{"id", "machineIdentifier", "key"}, []string{"name", "title"}, nil, cat)) refKeys.id, refKeys.name, refKeys.value, cat))
} }
} }
if len(users) > 0 && len(refs) == 0 { 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") 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, // read_logs — list sanitized log file IDs or read a bounded,
// sanitized slice of a single log file. // sanitized slice of a single log file.
func handleLogs(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult { func handleLogs(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
@@ -50,16 +78,10 @@ func (o *op) logsList() *ToolResult {
if fail != nil { if fail != nil {
return fail return fail
} }
arr, fail := o.decodeArray(raw) names, fail := o.logFileNames(raw)
if fail != nil { if fail != nil {
return fail return fail
} }
names := []string{}
for _, m := range arr {
if s := logFileName(m); s != "" {
names = append(names, s)
}
}
sort.Strings(names) sort.Strings(names)
out := &Logs{Kind: "logs", Files: []LogFile{}} out := &Logs{Kind: "logs", Files: []LogFile{}}
for _, n := range names { for _, n := range names {
@@ -97,13 +119,13 @@ func (o *op) logsRead(a *LogsArgs) *ToolResult {
if fail != nil { if fail != nil {
return fail return fail
} }
arr, fail := o.decodeArray(raw) names, fail := o.logFileNames(raw)
if fail != nil { if fail != nil {
return fail return fail
} }
name := "" name := ""
for _, m := range arr { for _, n := range names {
if n := logFileName(m); n != "" && logFileID(n) == a.FileID { if logFileID(n) == a.FileID {
name = n name = n
break break
} }
@@ -119,11 +141,15 @@ func (o *op) logsRead(a *LogsArgs) *ToolResult {
raw = raw[:maxLogBodyBytes] raw = raw[:maxLogBodyBytes]
o.truncated = true o.truncated = true
} }
text := sanitizeText(string(raw), maxLogBodyBytes) // Sanitize individual lines so control characters and unbounded text do
lines := strings.Split(text, "\n") // not leak while preserving the upstream line boundaries for pagination.
lines := strings.Split(string(raw), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" { if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = 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} out := &Logs{Kind: "logs", Lines: []string{}, Offset: &offset}
if offset < len(lines) { if offset < len(lines) {
end := offset + limit end := offset + limit
+15 -1
View File
@@ -128,8 +128,22 @@ func (o *op) mediaDetails(a *mediaCallArgs) *ToolResult {
if fail != nil { if fail != nil {
return fail 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", 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 { func (o *op) mediaByRequest(a *mediaCallArgs) *ToolResult {
+53 -11
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"strconv" "strconv"
"strings"
"ombi-mcp/internal/ombi" "ombi-mcp/internal/ombi"
"ombi-mcp/internal/translate" "ombi-mcp/internal/translate"
@@ -447,6 +448,13 @@ func (o *op) projectRequest(m map[string]any, kind string) Request {
r.Identifiers = addID(r.Identifiers, "tmdb", m["externalProviderId"]) r.Identifiers = addID(r.Identifiers, "tmdb", m["externalProviderId"])
r.Identifiers = addID(r.Identifiers, "tmdb", m["mediaId"]) r.Identifiers = addID(r.Identifiers, "tmdb", m["mediaId"])
r.Identifiers = addID(r.Identifiers, "imdb", m["imdbId"]) 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": case "album":
r.Identifiers = addID(r.Identifiers, "musicbrainz", m["foreignAlbumId"]) r.Identifiers = addID(r.Identifiers, "musicbrainz", m["foreignAlbumId"])
r.Identifiers = addID(r.Identifiers, "musicbrainz", m["mediaId"]) r.Identifiers = addID(r.Identifiers, "musicbrainz", m["mediaId"])
@@ -604,38 +612,72 @@ func (o *op) projectCalendarEntry(m map[string]any) CalendarEntry {
// refOf projects one upstream object into a reference record using // refOf projects one upstream object into a reference record using
// the first present key from each candidate list. // 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} r := Reference{Category: category}
var idRaw any
for _, k := range idKeys { for _, k := range idKeys {
if s, ok := toStr(m[k]); ok && s != "" { if s, ok := toStr(m[k]); ok && s != "" {
r.ID = s r.ID = s
idRaw = m[k]
break break
} }
} }
var firstCorrupted string
for _, k := range nameKeys { for _, k := range nameKeys {
if s, ok := m[k].(string); ok && s != "" { if s, ok := m[k].(string); ok && s != "" {
if !corrupted(s) {
r.Name = s r.Name = s
firstCorrupted = ""
break break
} }
if firstCorrupted == "" {
firstCorrupted = s
} }
if val == nil { }
r.Value = firstScalar(m) }
} else { if r.Name == "" && firstCorrupted != "" {
r.Value = val 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 return r
} }
// firstScalar returns the first scalar property value in a map for func corrupted(s string) bool {
// fallback reference values; iteration order makes this best-effort, t := strings.TrimSpace(s)
// so callers should prefer explicit keys where known. if t == "" {
func firstScalar(m map[string]any) any { return false
for _, v := range m { }
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) { switch v.(type) {
case string, float64, bool: case string, float64, bool, int, int64:
return v return v
} }
} }
}
return nil return nil
} }
+187
View File
@@ -145,3 +145,190 @@ func TestProjectRequestListStillUsesEntityID(t *testing.T) {
t.Errorf("target.id = %d, want 10", r.Target.ID) 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)) items := make([]Reference, 0, len(arr))
for _, m := range 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 return capItems(o, items), nil
} }
@@ -114,7 +114,7 @@ func (o *op) refKeyword(a *ReferenceArgs) *ToolResult {
if fail != nil { if fail != nil {
return fail 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", return o.ok(&ReferencePage{Kind: "reference_page",
Items: []Reference{r}, Page: singlePage(1, "references")}) Items: []Reference{r}, Page: singlePage(1, "references")})
} }
+1 -1
View File
@@ -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_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_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_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}, {"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_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}, {"write_request_subscribe", "core", "Subscribe/unsubscribe.", false, false, false, true, []string{"mutation"}, handleRequestSubscribe},
+65 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
) )
// read_requests — v2 list/status routes, single gets, TV children, // 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") return o.invalid("media", "media must be movie|tv|album")
} }
raw, fail := o.call("GET", path+seg(a.Query), nil, nil) 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 { if fail != nil {
return fail return fail
} }
@@ -188,6 +208,11 @@ func (o *op) requestsRecent(a *RequestsListArgs) *ToolResult {
return fail return fail
} }
items := []Request{} items := []Request{}
type pendingTV struct {
idx int
key tvRecentKey
}
pending := []pendingTV{}
for _, m := range arr { for _, m := range arr {
code, label := o.requestTypeTwin(m["type"]) code, label := o.requestTypeTwin(m["type"])
kind := "movie" kind := "movie"
@@ -203,17 +228,54 @@ func (o *op) requestsRecent(a *RequestsListArgs) *ToolResult {
kind = "movie" kind = "movie"
} }
r := o.projectRequest(m, kind) r := o.projectRequest(m, kind)
// RecentlyRequestedModel.RequestId is the Ombi request id. if kind == "tv_parent" {
// Extra `id` on a TV payload is a provider id and must not // On TV rows RecentlyRequestedModel.requestId is the CHILD
// occupy target.id even when requestId is absent. // 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 { if id, ok := toInt(m["requestId"]); ok && id > 0 {
r.Target.ID = id r.Target.ID = id
} else { } else {
r.Target.ID = 0 r.Target.ID = 0
} }
}
r.RequestedUserID = jstr(m, "userId") r.RequestedUserID = jstr(m, "userId")
items = append(items, r) 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") win, pg := localWindow(o, items, a.Page, "requests")
return o.ok(&RequestPage{Kind: "request_page", Items: win, Page: pg}) 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")
}
}
+4 -13
View File
@@ -1796,18 +1796,6 @@
], ],
"additionalProperties": false "additionalProperties": false
}, },
{
"type": "object",
"properties": {
"action": {
"const": "news"
}
},
"required": [
"action"
],
"additionalProperties": false
},
{ {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1848,7 +1836,9 @@
} }
}, },
"required": [ "required": [
"action" "action",
"from",
"to"
], ],
"additionalProperties": false "additionalProperties": false
}, },
@@ -1860,6 +1850,7 @@
}, },
"expression": { "expression": {
"type": "string", "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, "minLength": 1,
"maxLength": 200 "maxLength": 200
} }
+13 -27
View File
@@ -8,7 +8,7 @@ import (
"ombi-mcp/internal/ombi" "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, // administrator-gated cron validation. Families: metrics,
// reference_page. // reference_page.
func handleServer(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult { 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() return o.serverUpdateInfo()
case "update_check": case "update_check":
return o.serverUpdateCheck() return o.serverUpdateCheck()
case "news":
return o.serverNews()
case "landing": case "landing":
return o.serverLanding() return o.serverLanding()
case "features": case "features":
@@ -137,20 +135,6 @@ func (o *op) serverUpdateCheck() *ToolResult {
Values: []Metric{metric("update_available", v, "instance", "")}}) 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 { func (o *op) serverLanding() *ToolResult {
raw, fail := o.call("GET", "/api/v1/LandingPage", nil, nil) raw, fail := o.call("GET", "/api/v1/LandingPage", nil, nil)
if fail != nil { if fail != nil {
@@ -196,8 +180,14 @@ func (o *op) serverFeatures() *ToolResult {
} }
func (o *op) serverStats(a *ServerArgs) *ToolResult { func (o *op) serverStats(a *ServerArgs) *ToolResult {
// from <= to must be enforced by the server per the contract. if !nonempty(a.From) || !nonempty(a.To) {
if a.From != "" && 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) f, ferr := time.Parse(time.RFC3339, a.From)
t, terr := time.Parse(time.RFC3339, a.To) t, terr := time.Parse(time.RFC3339, a.To)
if ferr != nil || terr != nil { if ferr != nil || terr != nil {
@@ -206,14 +196,7 @@ func (o *op) serverStats(a *ServerArgs) *ToolResult {
if f.After(t) { if f.After(t) {
return o.invalid("from", "from must be on or before to") return o.invalid("from", "from must be on or before to")
} }
} q := map[string]string{"from": a.From, "to": a.To}
q := map[string]string{}
if a.From != "" {
q["from"] = a.From
}
if a.To != "" {
q["to"] = a.To
}
raw, fail := o.call("GET", "/api/v1/Stats", q, nil) raw, fail := o.call("GET", "/api/v1/Stats", q, nil)
if fail != nil { if fail != nil {
return fail return fail
@@ -256,5 +239,8 @@ func (o *op) serverCronValidate(a *ServerArgs) *ToolResult {
if s := jstr(m, "message"); s != "" { if s := jstr(m, "message"); s != "" {
vals = append(vals, metric("message", s, "instance", "")) 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}) 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]) 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 // flattenSettings walks a decoded settings document emitting one
// scalar leaf per Change with escaped-JSON-Pointer-style names. // 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) { func flattenSettings(v any, path string, out *[]Change, omitted *[]string) {
switch t := v.(type) { switch t := v.(type) {
case map[string]any: case map[string]any:
@@ -155,7 +183,7 @@ func flattenSettings(v any, path string, out *[]Change, omitted *[]string) {
sort.Strings(keys) sort.Strings(keys)
for _, k := range keys { for _, k := range keys {
child := path + "/" + escapePointer(k) child := path + "/" + escapePointer(k)
if excludedSettingsFields[k] || secretish(k) { if (excludedSettingsFields[k] || secretish(k)) && !serverIdentityLeaf(path, k) {
*omitted = append(*omitted, child) *omitted = append(*omitted, child)
continue continue
} }
@@ -165,7 +193,7 @@ func flattenSettings(v any, path string, out *[]Change, omitted *[]string) {
for i, e := range t { for i, e := range t {
flattenSettings(e, fmt.Sprintf("%s/%d", path, i), out, omitted) flattenSettings(e, fmt.Sprintf("%s/%d", path, i), out, omitted)
} }
case string, float64, bool: case string, float64, bool, int, int64:
name := path name := path
if name == "" { if name == "" {
name = "/" name = "/"