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
20 changed files with 782 additions and 128 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. |
+3 -3
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 |
@@ -120,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”.
@@ -173,7 +173,7 @@ 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.
+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
} }
+1 -1
View File
@@ -17,7 +17,7 @@ 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. |
+5 -5
View File
@@ -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<[Tag](../api/raml/types/Ombi.Api.External.ExternalApis.Sonarr.Models.Tag.raml)> | — | | 326 | [GET `/api/v1/Sonarr/tags`](../api/raml/api.raml#L5783) | D | `read_integration / options:sonarr/tags` | none documented | 200: array<[Tag](../api/raml/types/Ombi.Api.External.ExternalApis.Sonarr.Models.Tag.raml)> | — |
| 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. |
+7
View File
@@ -317,3 +317,10 @@ The gated live suite is committed and ready; run it with a sourced `.env`. It ad
### Residual Verify items ### Residual Verify items
- Verify `PlexServersAddUserModel` and older Ombi instances where `servers` may be returned as a bare array (now tolerated alongside object wrappers). - 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. - 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.
+227 -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)
} }
+92
View File
@@ -71,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) {
@@ -477,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{
+103 -9
View File
@@ -62,7 +62,12 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
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/{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))
@@ -419,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"},
@@ -494,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},
} }
} }
@@ -575,18 +586,27 @@ func (m *mockOmbi) movieList(w http.ResponseWriter, r *http.Request) {
func (m *mockOmbi) tvParentList(w http.ResponseWriter, r *http.Request) { func (m *mockOmbi) tvParentList(w http.ResponseWriter, r *http.Request) {
pos := r.PathValue("pos") pos := r.PathValue("pos")
if pos != "0" { if pos != "0" {
m.json([]any{})(w, r) m.json(map[string]any{"collection": []any{}, "total": 1})(w, r)
return return
} }
m.json([]any{ // 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{ map[string]any{
"id": 42, "id": 42,
"tvDbId": 81189, "tvDbId": 81189,
"externalProviderId": 1396,
"imdbId": "tt0903747",
"title": "trigger-500 and something", // For search fallback test "title": "trigger-500 and something", // For search fallback test
"childRequests": []any{ "childRequests": []any{
map[string]any{ map[string]any{
// A real (non-provider-shaped) child request
// id — recent rows reference this in requestId.
"id": 88,
"seasonRequests": []any{ "seasonRequests": []any{
map[string]any{ map[string]any{
"childRequestId": 88,
"seasonNumber": 1, "seasonNumber": 1,
"episodes": []any{ "episodes": []any{
map[string]any{"episodeNumber": 1, "requested": true}, map[string]any{"episodeNumber": 1, "requested": true},
@@ -598,9 +618,41 @@ func (m *mockOmbi) tvParentList(w http.ResponseWriter, r *http.Request) {
}, },
}, },
}, },
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) })(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{
@@ -752,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)
} }
@@ -863,6 +926,37 @@ func (m *mockOmbi) plexLibraries(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"successful":true,"data":[{"id":"3","key":"3","type":"show","title":"TV Shows"},{"id":"1","key":"1","type":"movie","title":"Movies"}]}`)) 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"`
+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")
+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
+4 -1
View File
@@ -133,7 +133,10 @@ func (o *op) mediaDetails(a *mediaCallArgs) *ToolResult {
// #7 request state overlay for tvdb // #7 request state overlay for tvdb
if t.Media == "tv" && t.Provider == "tvdb" { if t.Media == "tv" && t.Provider == "tvdb" {
reqVal := jbool(m, "requested") reqVal := jbool(m, "requested")
if (reqVal == nil || !*reqVal) && jint(m, "requestId") == nil { // 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() id, _ := t.idInt()
o.overlayTVRequestState(&it, id, jstr(m, "imdbId")) o.overlayTVRequestState(&it, id, jstr(m, "imdbId"))
} }
+7
View File
@@ -448,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"])
+40
View File
@@ -208,6 +208,46 @@ func TestMergeTVRequestStateNoMatch(t *testing.T) {
} }
} }
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) { func TestRefOfDeterministicValue(t *testing.T) {
o := &op{} o := &op{}
m := map[string]any{ m := map[string]any{
+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},
+45 -3
View File
@@ -208,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"
@@ -223,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})
} }
+143 -1
View File
@@ -1,7 +1,9 @@
package tools package tools
import ( import (
"bytes"
"fmt" "fmt"
"strconv"
) )
// eachTVRequestParent iterates GET /api/v1/Request/tv/{count}/{pos}/1/0/0 // eachTVRequestParent iterates GET /api/v1/Request/tv/{count}/{pos}/1/0/0
@@ -18,7 +20,7 @@ func (o *op) eachTVRequestParent(fn func(map[string]any) bool) (truncated bool,
if fail != nil { if fail != nil {
return false, fail return false, fail
} }
arr, fail := o.decodeArray(raw) arr, fail := o.decodeTVParentPage(raw)
if fail != nil { if fail != nil {
return false, fail return false, fail
} }
@@ -38,6 +40,146 @@ func (o *op) eachTVRequestParent(fn func(map[string]any) bool) (truncated bool,
return true, nil // hit cap 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. // mergeTVRequestState attempts to match and apply request state from a parent record p.
// Returns true if the parent matched (stopping the scan). // Returns true if the parent matched (stopping the scan).
func mergeTVRequestState(it *Media, p map[string]any, tvdbID int, imdbID string) bool { func mergeTVRequestState(it *Media, p map[string]any, tvdbID int, imdbID string) bool {
+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})
} }