Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
958006cff2 | ||
|
|
7658bc8f39 | ||
|
|
6926a91c80 | ||
|
|
5b1be698d6 | ||
|
|
d07ef4f818 | ||
|
|
ff18e60d5a | ||
|
|
0ce9e2626b | ||
|
|
40155b5981 | ||
|
|
012f6c0852 |
@@ -115,7 +115,7 @@ Tools are grouped into **bundles** — deployment policy groups, not permission
|
||||
| `read_votes` | Global vote list or votes on a request. |
|
||||
| `read_users` | Self, authorized user lookup, claims, online users, preference read. |
|
||||
| `read_library` | Recent additions, calendar, artwork. |
|
||||
| `read_server` | Server status, version, features, news, stats, cron validation. |
|
||||
| `read_server` | Server status, version, features, stats, cron validation. |
|
||||
| `read_integration` | Saved ARR options and authorized media-server metadata. |
|
||||
| `write_request_create` | Create one media request or an explicit collection request. |
|
||||
| `write_request_subscribe` | Subscribe/unsubscribe to a request. |
|
||||
|
||||
@@ -22,7 +22,7 @@ The [input catalogue](03-input-schemas.md) defines every parameter, required fie
|
||||
| `read_votes` | Global vote list or votes on a request | core | vote page |
|
||||
| `read_users` | Self, authorized user lookup, claims, online users, preference read | core | user/reference page |
|
||||
| `read_library` | Recent additions, calendar, artwork | core | media/calendar/artwork page |
|
||||
| `read_server` | Status, version, features, news, stats, cron validation | core | metrics/reference page |
|
||||
| `read_server` | Status, version, features, stats, cron validation | core | metrics/reference page |
|
||||
| `read_integration` | Saved ARR options and authorized media-server metadata | core | reference/user page |
|
||||
| `write_request_create` | One media request or explicit collection request | core | mutation |
|
||||
| `write_request_subscribe` | Subscribe/unsubscribe | core | mutation |
|
||||
@@ -111,7 +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.
|
||||
|
||||
`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.
|
||||
`read_media.details` chooses v2 movie TMDB/IMDb, TV TVDB/TMDB, artist, or artist/album routes. TV `tmdb` uses v2 `Search/tv/moviedb/{id}`; TV `tvdb` uses the legacy v1 `Search/tv/info/{tvdbId}` route (the v2 `Search/tv/{tvdbId}` route is a TMDB-keyed alias and must never serve a TVDB lookup). The legacy v1 TVDB info route does not reliably carry request state; for TVDB, the adapter performs a bounded scan of v1 `Request/tv` (matching by `tvDbId` or `imdbId`) to overlay `requested`, `request_targets` (using the real parent request ID), and per-episode availability flags. IMDb path placeholder spelling differs from its parameter declaration. `by_request` chooses movie/request, tv/request or artist/request. `movie_localized` uses POST v1 movie/info. `ratings` first uses Ombi's title/year ratings route. Ombi 4.53.x still routes that operation through removed Rotten Tomatoes private endpoints; on a 404/5xx (or a successful empty response), the adapter searches the same media/title and requires an exact case-insensitive title and year match before returning explicitly labelled `tmdb_vote_*` or `tvmaze_site_rating` references. A warning identifies the fallback source; no fuzzy match is substituted. `streaming` uses TMDB even for TV.
|
||||
TV identifier labels follow the origin route, not the upstream field name. The v1 TVMaze-backed routes (`Search/tv/{term}`, `Search/tv/info/{tvdbId}`) place the TVDB id in the misleadingly-named `theMovieDbId` field and the TVMaze id in `seriesId`; emit `tvdb` and `tvmaze` there. The v2 engine routes (browse, `moviedb`, `by_request`, multi-search) are TMDB-keyed; emit `tmdb` and do not treat `seriesId` as TVMaze. When `theMovieDbId` is absent, `id` is labelled with that origin namespace so browse and collection members still carry a usable identifier. Multi-search `mediaType` is case-insensitive (`Artist`→artist). The v1 `RecentlyAdded` TV model's id namespace is unverified — keep `tmdb` pending live confirmation.
|
||||
|
||||
`read_reference.keywords` passes query `searchTerm`; keyword detail uses its own keyword ID. Watch-provider catalogue search also has optional `searchTerm`; it is distinct from streaming availability for a particular title. Other branches have no request body. Reference values are good optional cached resources, but remain available through tools for tool-only clients.
|
||||
@@ -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.
|
||||
|
||||
`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”.
|
||||
|
||||
@@ -159,7 +159,7 @@ Issue `list` uses v1 paged issues and returns individual records. `summary` uses
|
||||
|
||||
Issue creation maps only title, subject, description, `issueCategoryId`, numeric `requestType`, and optional `requestId`/`providerId`. Require at least one association by design; RAML marks these properties optional and does not establish a server requirement. If both are provided, verify they refer to the same media. The server supplies author, timestamps and state; never accept `userReported`, comments, resolved date or persistence ID from the model. Comment POST uses `{comment, issueId}`. Status POST uses `{issueId, status}`. Category POST uses `{value}`. Delete IDs are in the path; no speculative update-category endpoint exists.
|
||||
|
||||
Vote reads use `Vote/music/{requestId}` for albums; writes use `Vote/{up|down}/album/{requestId}`. Do not derive both directions from one generic segment rule. Vote toggling/repeated-call behaviour is unverified, hence no idempotence promise.
|
||||
Vote reads use `Vote/music/{requestId}` for albums; writes use `Vote/{up|down}/album/{requestId}`. Do not derive both directions from one generic segment rule. Vote toggling/repeated-call behaviour is unverified, hence no idempotence promise. On Ombi 4.53.10, the global vote view and movie vote-record route can return HTTP 500 for existing instance data while the corresponding empty TV route succeeds. Ombi exposes no equivalent read route from which the adapter can reconstruct vote ownership or totals. Preserve `UPSTREAM_REJECTED` in that case; never convert the failure into a fabricated empty `vote_page`.
|
||||
|
||||
User GET-by-ID is `/Identity/User/{id}`. `self`, all users, dropdown, claims, online and notification preferences each have their exact separate routes. Respect hide-user settings and upstream visibility; do not disclose hidden requester IDs simply to populate a normalized field.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -987,7 +987,7 @@ Read issue records, provider-grouped summaries, comments or counts.
|
||||
|
||||
## read_votes
|
||||
|
||||
Read all voteable requests or votes for a movie, TV or album request.
|
||||
Read all voteable requests or votes for a movie, TV or album request. Some Ombi 4.53.x data sets make the global or movie read routes fail upstream; the tool reports `UPSTREAM_REJECTED` rather than treating unknown votes as an empty page.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1494,18 +1494,6 @@ Read server status, feature availability, version, update information or usage s
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"const": "news"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1546,7 +1534,9 @@ Read server status, feature availability, version, update information or usage s
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action"
|
||||
"action",
|
||||
"from",
|
||||
"to"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -1558,6 +1548,7 @@ Read server status, feature availability, version, update information or usage s
|
||||
},
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "Quartz cron expression — 6 or 7 fields (seconds minutes hours day-of-month month day-of-week [year]); one of the two day fields must be ?",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ Each per-tool output schema is intentionally a bounded projection, not the recur
|
||||
| Family | Projection rules |
|
||||
|---|---|
|
||||
| media_page | Search/discovery/details return zero or more normalized media records. Details usually has one item. Keep every known ID namespace; 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. |
|
||||
| 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. |
|
||||
|
||||
@@ -234,8 +234,8 @@ The body/response columns describe the **upstream** schema, not a promise to pas
|
||||
| 216 | [GET `/api/v2/Search/artist/request/{requestId}`](../api/raml/api.raml#L3949) | D | `read_media / by_request` | path `requestId`:integer required | 200: [ArtistInformation](../api/raml/types/Ombi.Core.Models.Search.V2.Music.ArtistInformation.raml) | Request-ID namespace requires verification. MCP `media=album` maps to this literal `artist` route segment. |
|
||||
| 217 | [GET `/api/v2/Search/artist/album/{albumId}`](../api/raml/api.raml#L3976) | D | `read_media / details` | path `albumId`:string required | 200: [ReleaseGroup](../api/raml/types/Ombi.Core.Models.Search.V2.Music.ReleaseGroup.raml) | — |
|
||||
| 218 | [GET `/api/v2/Search/releasegroupart/{musicBrainzId}`](../api/raml/api.raml#L4003) | D | `read_library / album_art` | path `musicBrainzId`:string required | 200: [AlbumArt](../api/raml/types/Ombi.Core.Models.Search.V2.Music.AlbumArt.raml) | — |
|
||||
| 219 | [GET `/api/v2/Search/ratings/movie/{name}/{year}`](../api/raml/api.raml#L4030) | D | `read_media / ratings` | path `name`:string required; path `year`:integer required | 200: [MovieRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.MovieRatings.raml) | — |
|
||||
| 220 | [GET `/api/v2/Search/ratings/tv/{name}/{year}`](../api/raml/api.raml#L4060) | D | `read_media / ratings` | path `name`:string required; path `year`:integer required | 200: [TvRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.TvRatings.raml) | — |
|
||||
| 219 | [GET `/api/v2/Search/ratings/movie/{name}/{year}`](../api/raml/api.raml#L4030) | D | `read_media / ratings` | path `name`:string required; path `year`:integer required | 200: [MovieRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.MovieRatings.raml) | Ombi 4.53.x depends on removed Rotten Tomatoes private endpoints; on 404/5xx/empty results, fall back to an exact title/year v1 movie search and label TMDB rating fields. |
|
||||
| 220 | [GET `/api/v2/Search/ratings/tv/{name}/{year}`](../api/raml/api.raml#L4060) | D | `read_media / ratings` | path `name`:string required; path `year`:integer required | 200: [TvRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.TvRatings.raml) | Ombi 4.53.x depends on removed Rotten Tomatoes private endpoints; on 404/5xx/empty results, fall back to an exact title/year v1 TV search and label TVMaze rating fields. |
|
||||
| 221 | [GET `/api/v2/Search/stream/movie/{movieDbId}`](../api/raml/api.raml#L4090) | D | `read_media / streaming` | path `movieDBId`:integer required | 200: array<[StreamingData](../api/raml/types/Ombi.Core.Models.Search.V2.StreamingData.raml)> | — |
|
||||
| 222 | [GET `/api/v2/Search/stream/tv/{movieDbId}`](../api/raml/api.raml#L4120) | D | `read_media / streaming` | path `movieDbId`:integer required | 200: array<[StreamingData](../api/raml/types/Ombi.Core.Models.Search.V2.StreamingData.raml)> | — |
|
||||
| 223 | [GET `/api/v1/Search/movie/{searchTerm}`](../api/raml/api.raml#L4150) | D | `read_search / text` | path `searchTerm`:unspecified required | 200: array<[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)> | — |
|
||||
@@ -298,7 +298,7 @@ The body/response columns describe the **upstream** schema, not a promise to pas
|
||||
| 280 | [GET `/api/v1/Settings/SickRage`](../api/raml/api.raml#L5100) | D | `read_settings / sickrage` | none documented | 200: [SickRageSettings](../api/raml/types/Ombi.Settings.Settings.Models.External.SickRageSettings.raml) | Allowlisted projection; no raw secrets. |
|
||||
| 281 | [GET `/api/v1/Settings/jobs`](../api/raml/api.raml#L5130) | D | `read_settings / jobs` | none documented | 200: [JobSettings](../api/raml/types/Ombi.Settings.Settings.Models.JobSettings.raml) | Allowlisted projection; no raw secrets. |
|
||||
| 282 | [POST `/api/v1/Settings/jobs`](../api/raml/api.raml#L5130) | P | `write_settings_patch / patch:jobs` | body [JobSettings](../api/raml/types/Ombi.Settings.Settings.Models.JobSettings.raml) | 200: [JobSettingsViewModel](../api/raml/types/Ombi.Models.JobSettingsViewModel.raml) | Only typed non-secret patch; preserve omitted/private fields. |
|
||||
| 283 | [POST `/api/v1/Settings/testcron`](../api/raml/api.raml#L5160) | D | `read_server / cron_validate` | body [CronViewModelBody](../api/raml/types/Ombi.Models.CronViewModelBody.raml) | 200: [CronTestModel](../api/raml/types/Ombi.Models.CronTestModel.raml) | Administrator-gated read calculation. |
|
||||
| 283 | [POST `/api/v1/Settings/testcron`](../api/raml/api.raml#L5160) | D | `read_server / cron_validate` | body [CronViewModelBody](../api/raml/types/Ombi.Models.CronViewModelBody.raml) | 200: [CronTestModel](../api/raml/types/Ombi.Models.CronTestModel.raml) | Administrator-gated read calculation; Quartz uses 6-7 fields and `?` for the unused day field. |
|
||||
| 284 | [POST `/api/v1/Settings/Issues`](../api/raml/api.raml#L5178) | P | `write_settings_patch / patch:issues` | body [IssueSettings](../api/raml/types/Ombi.Settings.Settings.Models.IssueSettings.raml) | 200: boolean | Only typed non-secret patch; preserve omitted/private fields. |
|
||||
| 285 | [GET `/api/v1/Settings/Issues`](../api/raml/api.raml#L5178) | D | `read_settings / issues` | none documented | 200: [IssueSettings](../api/raml/types/Ombi.Settings.Settings.Models.IssueSettings.raml) | Allowlisted projection; no raw secrets. |
|
||||
| 286 | [GET `/api/v1/Settings/issuesenabled`](../api/raml/api.raml#L5208) | D | `read_settings / issuesenabled` | none documented | 200: boolean | Allowlisted projection; no raw secrets. |
|
||||
@@ -344,12 +344,12 @@ The body/response columns describe the **upstream** schema, not a promise to pas
|
||||
| 326 | [GET `/api/v1/Sonarr/tags`](../api/raml/api.raml#L5783) | D | `read_integration / options:sonarr/tags` | none documented | 200: array<[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 | — |
|
||||
| 328 | [GET `/api/v1/Sonarr/version`](../api/raml/api.raml#L5824) | D | `read_integration / options:sonarr/version` | none documented | 200: string | — |
|
||||
| 329 | [GET `/api/v1/Stats`](../api/raml/api.raml#L5833) | D | `read_server / stats` | query `from`:string optional; query `to`:string optional | 200: [UserStatsSummary](../api/raml/types/Ombi.Core.Engine.UserStatsSummary.raml) | from/to query parameters are supported. |
|
||||
| 329 | [GET `/api/v1/Stats`](../api/raml/api.raml#L5833) | D | `read_server / stats` | query `from`:string required; query `to`:string required | 200: [UserStatsSummary](../api/raml/types/Ombi.Core.Engine.UserStatsSummary.raml) | from/to are required at the MCP layer; bare calls cause an upstream NRE. |
|
||||
| 330 | [GET `/api/v1/Status`](../api/raml/api.raml#L5849) | D | `read_server / status` | none documented | 200: [HttpStatusCode](../api/raml/types/System.Net.HttpStatusCode.raml) | — |
|
||||
| 331 | [GET `/api/v1/Status/info`](../api/raml/api.raml#L5860) | D | `read_server / status_info` | none documented | 200: string | — |
|
||||
| 332 | [GET `/api/v2/System/news`](../api/raml/api.raml#L5871) | D | `read_server / news` | none documented | 200: body unspecified | — |
|
||||
| 333 | [GET `/api/v2/System/logs`](../api/raml/api.raml#L5877) | D | `read_logs / list` | none documented | 200: body unspecified | — |
|
||||
| 334 | [GET `/api/v2/System/logs/{logFileName}`](../api/raml/api.raml#L5883) | D | `read_logs / read` | path `logFileName`:string required | 200: body unspecified | Vetted opaque ID maps to filename; sanitized bounded local slicing. |
|
||||
| 332 | [GET `/api/v2/System/news`](../api/raml/api.raml#L5871) | X | action removed | none documented | 200: Markdig HTML text | Not structured data; action removed (#13). |
|
||||
| 333 | [GET `/api/v2/System/logs`](../api/raml/api.raml#L5877) | D | `read_logs / list` | none documented | 200: string array of file names | Verified on 4.53.10. |
|
||||
| 334 | [GET `/api/v2/System/logs/{logFileName}`](../api/raml/api.raml#L5883) | D | `read_logs / read` | path `logFileName`:string required | 200: plain text | Vetted opaque ID maps to filename; sanitized bounded local slicing (verified 4.53.10). |
|
||||
| 335 | [GET `/api/v2/System/logs/download/{logFileName}`](../api/raml/api.raml#L5893) | X | `raw diagnostic download` | path `logFileName`:string required | 200: body unspecified | May expose secrets; sanitized logs tool is the supported alternative. |
|
||||
| 336 | [POST `/api/v1/Tester/discord`](../api/raml/api.raml#L5903) | P | `write_integration_test / discord` | body [DiscordNotificationSettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.DiscordNotificationSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
|
||||
| 337 | [POST `/api/v1/Tester/pushbullet`](../api/raml/api.raml#L5923) | P | `write_integration_test / pushbullet` | body [PushbulletSettings](../api/raml/types/Ombi.Settings.Settings.Models.Notifications.PushbulletSettings.raml) | 200: boolean | Saved authorized profile only; exact tester body differs by service. |
|
||||
@@ -383,13 +383,13 @@ The body/response columns describe the **upstream** schema, not a promise to pas
|
||||
| 365 | [POST `/api/v1/Token/requirePassword`](../api/raml/api.raml#L6464) | I | `upstream authentication adapter` | body [UserAuthModel](../api/raml/types/Ombi.Models.UserAuthModel.raml) | 200: boolean | Not model-callable; JWT-primary auth adapter (`OMBI_AUTH_MODE=jwt`); login/refresh semantics per `01-authentication.md`; Bearer-on-all-routes remains Verify. |
|
||||
| 366 | [POST `/api/v1/Token/header_auth`](../api/raml/api.raml#L6482) | I | `upstream authentication adapter` | none documented | 200: body unspecified | Not model-callable; JWT-primary auth adapter (`OMBI_AUTH_MODE=jwt`); login/refresh semantics per `01-authentication.md`; Bearer-on-all-routes remains Verify. |
|
||||
| 367 | [GET `/api/v1/Update`](../api/raml/api.raml#L6494) | D | `read_server / update_info` | none documented | 200: [UpdateModel](../api/raml/types/Ombi.Core.Processor.UpdateModel.raml) | — |
|
||||
| 368 | [GET `/api/v1/Vote`](../api/raml/api.raml#L6503) | D | `read_votes / list` | none documented | 200: array<[VoteViewModel](../api/raml/types/Ombi.Core.Models.UI.VoteViewModel.raml)> | — |
|
||||
| 368 | [GET `/api/v1/Vote`](../api/raml/api.raml#L6503) | D | `read_votes / list` | none documented | 200: array<[VoteViewModel](../api/raml/types/Ombi.Core.Models.UI.VoteViewModel.raml)> | Ombi 4.53.10 can return HTTP 500 for existing instance data; no lossless alternate read route exists, so preserve `UPSTREAM_REJECTED`. |
|
||||
| 369 | [POST `/api/v1/Vote/up/movie/{requestId}`](../api/raml/api.raml#L6515) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
|
||||
| 370 | [POST `/api/v1/Vote/up/tv/{requestId}`](../api/raml/api.raml#L6530) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
|
||||
| 371 | [POST `/api/v1/Vote/up/album/{requestId}`](../api/raml/api.raml#L6545) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
|
||||
| 372 | [POST `/api/v1/Vote/down/movie/{requestId}`](../api/raml/api.raml#L6560) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
|
||||
| 373 | [POST `/api/v1/Vote/down/tv/{requestId}`](../api/raml/api.raml#L6575) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
|
||||
| 374 | [POST `/api/v1/Vote/down/album/{requestId}`](../api/raml/api.raml#L6590) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
|
||||
| 375 | [GET `/api/v1/Vote/movie/{requestId}`](../api/raml/api.raml#L6605) | D | `read_votes / get` | path `requestId`:integer required | 200: array<[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)> | — |
|
||||
| 375 | [GET `/api/v1/Vote/movie/{requestId}`](../api/raml/api.raml#L6605) | D | `read_votes / get` | path `requestId`:integer required | 200: array<[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)> | Ombi 4.53.10 can return HTTP 500 for existing instance data; no lossless alternate read route exists, so preserve `UPSTREAM_REJECTED`. |
|
||||
| 376 | [GET `/api/v1/Vote/music/{requestId}`](../api/raml/api.raml#L6621) | D | `read_votes / get` | path `requestId`:integer required | 200: array<[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)> | — |
|
||||
| 377 | [GET `/api/v1/Vote/tv/{requestId}`](../api/raml/api.raml#L6637) | D | `read_votes / get` | path `requestId`:integer required | 200: array<[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)> | — |
|
||||
|
||||
@@ -317,3 +317,15 @@ The gated live suite is committed and ready; run it with a sourced `.env`. It ad
|
||||
### Residual Verify items
|
||||
- Verify `PlexServersAddUserModel` and older Ombi instances where `servers` may be returned as a bare array (now tolerated alongside object wrappers).
|
||||
- Verify Emby `selectedLibraries[].key` ↔ MediaFolders `id` correspondence across diverse Emby/Jellyfin setups.
|
||||
|
||||
## M7 Findings — Admin/server wire contracts (#19, #13, #14, #15)
|
||||
|
||||
- `#19`: `GET /api/v2/System/logs` returns a JSON string array of log file names on Ombi 4.53.10. The adapter accepts that form and the legacy object form, then reads the selected file as plain text.
|
||||
- `#13`: `GET /api/v2/System/news` returns Markdig-rendered HTML text rather than structured JSON. The `news` action was removed from `read_server`.
|
||||
- `#14`: `GET /api/v1/Stats` binds non-nullable `from` and `to` DateTimes; an empty range triggers an upstream null-reference error. The adapter requires both RFC3339 values before calling Ombi.
|
||||
- `#15`: Ombi validates Quartz.NET cron expressions. They have six or seven fields and require `?` in one of day-of-month or day-of-week; for example, `0 0 0 * * ?` validates while five-field cron and expressions with both day fields as `*` do not.
|
||||
|
||||
## M8 Findings — Upstream ratings and vote failures (#8, #12)
|
||||
|
||||
- `#8`: Ombi 4.53.10 implements both v2 ratings routes by calling `www.rottentomatoes.com/api/private`; both private endpoints now return HTTP 404 and Ombi surfaces the dependency failure as HTTP 500. `read_media ratings` retains the native result when available and otherwise performs an exact title/year lookup through Ombi's normal movie or TV search. Fallback values are source-labelled (`tmdb_vote_average`, `tmdb_vote_count`, or `tvmaze_site_rating`) and the result carries a degradation warning. No fuzzy title or year substitution is allowed.
|
||||
- `#12`: `GET /api/v1/Vote` and `GET /api/v1/Vote/movie/{requestId}` return HTTP 500 on the verified Ombi 4.53.10 data set, while an empty TV request returns HTTP 200 with `[]`. The global controller builds derived per-request summaries and the per-media controllers are the only raw vote-record reads; there is no second lossless API from which the MCP can recover user vote identity and counts. The adapter therefore preserves the sanitized, retryable `UPSTREAM_REJECTED` error and never substitutes an empty page. Mock coverage fixes this error boundary as part of the public contract.
|
||||
|
||||
@@ -3,11 +3,19 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testLogFileID(name string) string {
|
||||
sum := sha256.Sum256([]byte(name))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// --- protocol surface ---
|
||||
|
||||
func TestHandshakeAndToolList(t *testing.T) {
|
||||
@@ -36,6 +44,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) {
|
||||
mock := newMockOmbi(t, "jwt")
|
||||
env := mock.env()
|
||||
@@ -136,6 +274,86 @@ func TestReadMediaDetailsProjectsUndocumentedFields(t *testing.T) {
|
||||
assertNoLeak(t, out.Raw)
|
||||
}
|
||||
|
||||
func TestReadMediaRatingsFallsBackToSearchMetadata(t *testing.T) {
|
||||
mock := newMockOmbi(t, "jwt")
|
||||
c := spawnServer(t, mock.env())
|
||||
c.handshake(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
media string
|
||||
name string
|
||||
year int
|
||||
want string
|
||||
}{
|
||||
{media: "movie", name: "Alien", year: 1979, want: "tmdb_vote_average"},
|
||||
{media: "tv", name: "Breaking Bad", year: 2008, want: "tvmaze_site_rating"},
|
||||
} {
|
||||
out := c.callTool(t, "read_media", map[string]any{
|
||||
"action": "ratings", "media": tc.media, "name": tc.name, "year": tc.year,
|
||||
})
|
||||
data := requireOK(t, out)
|
||||
var page struct {
|
||||
Items []struct {
|
||||
Ratings []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"ratings"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &page); err != nil {
|
||||
t.Fatalf("%s ratings decode: %v\n%s", tc.media, err, data)
|
||||
}
|
||||
if len(page.Items) != 1 || len(page.Items[0].Ratings) == 0 ||
|
||||
page.Items[0].Ratings[0].Name != tc.want {
|
||||
t.Fatalf("%s fallback ratings: %s", tc.media, data)
|
||||
}
|
||||
if !strings.Contains(strings.Join(out.Envelope.Warnings, " "), "search metadata") {
|
||||
t.Errorf("%s fallback warning missing: %v", tc.media, out.Envelope.Warnings)
|
||||
}
|
||||
}
|
||||
if calls := mock.countCalls("GET", "/api/v2/Search/ratings/"); calls != 2 {
|
||||
t.Errorf("ratings endpoint calls = %d, want 2", calls)
|
||||
}
|
||||
if calls := mock.countCalls("GET", "/api/v1/Search/movie/Alien"); calls != 1 {
|
||||
t.Errorf("movie fallback calls = %d, want 1", calls)
|
||||
}
|
||||
if calls := mock.countCalls("GET", "/api/v1/Search/tv/Breaking Bad"); calls != 1 {
|
||||
t.Errorf("TV fallback calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadVotesPreservesUpstreamFailures(t *testing.T) {
|
||||
mock := newMockOmbi(t, "jwt")
|
||||
c := spawnServer(t, mock.env())
|
||||
c.handshake(t)
|
||||
|
||||
cases := []map[string]any{
|
||||
{"action": "list"},
|
||||
{"action": "get", "media": "movie", "request_id": 2207},
|
||||
}
|
||||
for _, args := range cases {
|
||||
out := c.callTool(t, "read_votes", args)
|
||||
err := requireErr(t, out, "UPSTREAM_REJECTED")
|
||||
if err.HTTPStatus == nil || *err.HTTPStatus != http.StatusInternalServerError {
|
||||
t.Errorf("vote read HTTP status = %v, want 500", err.HTTPStatus)
|
||||
}
|
||||
if !err.Retryable {
|
||||
t.Errorf("vote read 500 should retain generic retryable mapping")
|
||||
}
|
||||
if len(out.Envelope.Data) != 0 && string(out.Envelope.Data) != "null" {
|
||||
t.Errorf("vote read fabricated data after upstream failure: %s", out.Envelope.Data)
|
||||
}
|
||||
}
|
||||
|
||||
out := c.callTool(t, "read_votes", map[string]any{
|
||||
"action": "get", "media": "tv", "request_id": 759,
|
||||
})
|
||||
data := requireOK(t, out)
|
||||
if !strings.Contains(string(data), `"kind":"vote_page"`) ||
|
||||
!strings.Contains(string(data), `"items":[]`) {
|
||||
t.Fatalf("empty TV votes should remain a successful empty page: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRequestsListProjection(t *testing.T) {
|
||||
mock := newMockOmbi(t, "jwt")
|
||||
c := spawnServer(t, mock.env())
|
||||
@@ -218,6 +436,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) ---
|
||||
|
||||
func TestReadMediaDetailsTVDBUsesV1InfoRoute(t *testing.T) {
|
||||
@@ -512,6 +777,10 @@ func TestReadDiscoverRequestedFallsBackToRecentFeed(t *testing.T) {
|
||||
Namespace string `json:"namespace"`
|
||||
Value string `json:"value"`
|
||||
} `json:"identifiers"`
|
||||
RequestTargets []struct {
|
||||
Kind string `json:"kind"`
|
||||
ID int `json:"id"`
|
||||
} `json:"request_targets"`
|
||||
} `json:"items"`
|
||||
Page struct {
|
||||
Mode string `json:"mode"`
|
||||
@@ -535,6 +804,14 @@ func TestReadDiscoverRequestedFallsBackToRecentFeed(t *testing.T) {
|
||||
if ids["tmdb"] == "" {
|
||||
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)
|
||||
}
|
||||
if n := mock.countCalls("GET", "/api/v2/Requests/recentlyRequested"); n != 2 {
|
||||
@@ -646,7 +923,7 @@ func TestReadRequestsRecentTVParentUsesRequestID(t *testing.T) {
|
||||
if err := json.Unmarshal(data, &page); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, data)
|
||||
}
|
||||
var movie, tv *struct {
|
||||
items := map[string]*struct {
|
||||
Target struct {
|
||||
Kind string `json:"kind"`
|
||||
ID int `json:"id"`
|
||||
@@ -656,35 +933,50 @@ func TestReadRequestsRecentTVParentUsesRequestID(t *testing.T) {
|
||||
Namespace string `json:"namespace"`
|
||||
Value string `json:"value"`
|
||||
} `json:"identifiers"`
|
||||
}
|
||||
}{}
|
||||
for i := range page.Items {
|
||||
switch page.Items[i].Title {
|
||||
case "Yummy":
|
||||
movie = &page.Items[i]
|
||||
case "Seven Up!":
|
||||
tv = &page.Items[i]
|
||||
it := page.Items[i]
|
||||
items[it.Title] = &it
|
||||
}
|
||||
}
|
||||
if movie == nil || tv == nil {
|
||||
movie, tv, tvChildID, orphan := items["Yummy"], items["Seven Up!"],
|
||||
items["trigger-500 and something"], items["Provider Only"]
|
||||
if movie == nil || tv == nil || tvChildID == nil || orphan == nil {
|
||||
t.Fatalf("missing recent items: %s", data)
|
||||
}
|
||||
if movie.Target.Kind != "movie" || movie.Target.ID != 2207 {
|
||||
t.Errorf("movie target = %+v", movie.Target)
|
||||
}
|
||||
if tv.Target.Kind != "tv_parent" || tv.Target.ID != 88 {
|
||||
t.Errorf("tv_parent target = %+v, want request id 88 not provider 259032", tv.Target)
|
||||
// TV requestId 259032 is a provider-shaped child PK upstream; the
|
||||
// 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{}
|
||||
for _, id := range tv.Identifiers {
|
||||
tvIDs[id.Namespace] = id.Value
|
||||
}
|
||||
if tvIDs["tmdb"] != "259032" {
|
||||
t.Errorf("tv provider id missing from identifiers: %v", tvIDs)
|
||||
if tvIDs["tvdb"] != "259032" || tvIDs["tmdb"] != "118680" || tvIDs["imdb"] != "tt1439629" {
|
||||
t.Errorf("tv identifiers missing parent provider ids: %v", tvIDs)
|
||||
}
|
||||
for _, it := range page.Items {
|
||||
if it.Title == "Provider Only" && it.Target.ID == 111 {
|
||||
t.Errorf("provider id occupied target.id on requestId-less recent item: %+v", it.Target)
|
||||
if tvIDs["ombi_tv_child"] != "259032" {
|
||||
t.Errorf("child request id missing from identifiers: %v", tvIDs)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,50 @@ func TestLiveServerStatus(t *testing.T) {
|
||||
assertNoLeak(t, out.Raw)
|
||||
}
|
||||
|
||||
func TestLiveServerStats(t *testing.T) {
|
||||
c := liveServer(t)
|
||||
out := c.callTool(t, "read_server", map[string]any{
|
||||
"action": "stats", "from": "2026-09-01T00:00:00Z", "to": "2026-09-02T00:00:00Z",
|
||||
})
|
||||
data := requireOK(t, out)
|
||||
var metrics struct {
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &metrics); err != nil || metrics.Kind != "metrics" {
|
||||
t.Fatalf("stats result: %v: %s", err, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveLogs(t *testing.T) {
|
||||
c := liveServer(t)
|
||||
out := c.callTool(t, "read_logs", map[string]any{"action": "list"})
|
||||
data := requireOK(t, out)
|
||||
var logs struct {
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &logs); err != nil || logs.Kind != "logs" {
|
||||
t.Fatalf("logs result: %v: %s", err, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveCronValidateQuartz(t *testing.T) {
|
||||
c := liveServer(t)
|
||||
out := c.callTool(t, "read_server", map[string]any{
|
||||
"action": "cron_validate", "expression": "0 0 0 * * ?",
|
||||
})
|
||||
data := requireOK(t, out)
|
||||
if !strings.Contains(string(data), `"name":"valid","value":true`) {
|
||||
t.Fatalf("Quartz-valid expression reported invalid: %s", data)
|
||||
}
|
||||
out = c.callTool(t, "read_server", map[string]any{
|
||||
"action": "cron_validate", "expression": "0 0 * * *",
|
||||
})
|
||||
data = requireOK(t, out)
|
||||
if !strings.Contains(string(data), `"name":"valid","value":false`) {
|
||||
t.Fatalf("five-field cron reported valid: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
// --- read/projection contract against real payloads ---
|
||||
|
||||
func TestLiveReadRequestsList(t *testing.T) {
|
||||
@@ -477,6 +521,54 @@ func TestLiveDiscoverTVBrowseHasIdentifiers(t *testing.T) {
|
||||
assertNoLeak(t, out.Raw)
|
||||
}
|
||||
|
||||
func TestLiveRecentTVParentTargetIsCallable(t *testing.T) {
|
||||
c := liveServer(t)
|
||||
out := c.callTool(t, "read_requests", map[string]any{"action": "recent"})
|
||||
data := requireOK(t, out)
|
||||
var page struct {
|
||||
Items []struct {
|
||||
Target struct {
|
||||
Kind string `json:"kind"`
|
||||
ID int `json:"id"`
|
||||
} `json:"target"`
|
||||
Title string `json:"title"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &page); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, data)
|
||||
}
|
||||
var tv *struct {
|
||||
Target struct {
|
||||
Kind string `json:"kind"`
|
||||
ID int `json:"id"`
|
||||
} `json:"target"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
for i := range page.Items {
|
||||
if page.Items[i].Target.Kind == "tv_parent" {
|
||||
tv = &page.Items[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if tv == nil {
|
||||
t.Skip("live recent feed has no TV rows")
|
||||
}
|
||||
// Issue #11: upstream emits a provider-shaped child request id
|
||||
// here; the resolved tv_parent id must be callable via get.
|
||||
if tv.Target.ID < 1 {
|
||||
t.Skipf("recent tv row %q left unresolved (id 0)", tv.Title)
|
||||
}
|
||||
out = c.callTool(t, "read_requests", map[string]any{
|
||||
"action": "get",
|
||||
"target": map[string]any{"kind": "tv_parent", "id": tv.Target.ID},
|
||||
})
|
||||
if out.IsError {
|
||||
t.Fatalf("recent tv_parent target %d not callable via get (issue #11): %s",
|
||||
tv.Target.ID, out.Raw)
|
||||
}
|
||||
assertNoLeak(t, out.Raw)
|
||||
}
|
||||
|
||||
func TestLiveSearchMultiArtistMapped(t *testing.T) {
|
||||
c := liveServer(t)
|
||||
out := c.callTool(t, "read_search", map[string]any{
|
||||
|
||||
@@ -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/info", m.wrap(m.fixed(`"mock-status-info"`)))
|
||||
mux.HandleFunc("GET /api/v1/Settings/about", m.wrap(m.json(mockAbout())))
|
||||
mux.HandleFunc("GET /api/v1/Stats", m.wrap(m.stats))
|
||||
mux.HandleFunc("POST /api/v1/Settings/testcron", m.wrap(m.testcron))
|
||||
mux.HandleFunc("GET /api/v2/System/logs", m.wrap(m.fixed(`["ombi-20260918.txt","ombi-20260919.txt"]`)))
|
||||
mux.HandleFunc("GET /api/v2/System/logs/{logFileName}", m.wrap(m.logsRead))
|
||||
mux.HandleFunc("GET /api/v1/Request/tv/{count}/{pos}/{o}/{s}/{a}", m.wrap(m.tvParentList))
|
||||
mux.HandleFunc("GET /api/v1/Request/tv/{id}/child", m.wrap(m.tvChildren))
|
||||
mux.HandleFunc("GET /api/v2/Requests/movie/{amt}/{pos}/requestDate/{order}", m.wrap(m.movieList))
|
||||
mux.HandleFunc("GET /api/v2/Requests/tv/{amt}/{pos}/requestDate/{order}", m.wrap(m.tvList))
|
||||
mux.HandleFunc("GET /api/v2/Search/movie/{id}", m.wrap(m.movieDetails))
|
||||
@@ -78,9 +83,11 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
|
||||
mux.HandleFunc("GET /api/v2/Search/actor/{id}/tv", m.wrap(m.actorTVCredits))
|
||||
mux.HandleFunc("POST /api/v2/Search/advancedSearch/movie/{pos}/{amt}", m.wrap(m.advancedMovie))
|
||||
mux.HandleFunc("POST /api/v2/Search/multi/{term}", m.wrap(m.multiSearch))
|
||||
mux.HandleFunc("GET /api/v2/Search/ratings/{media}/{name}/{year}", m.wrap(m.ratingsUnavailable))
|
||||
mux.HandleFunc("GET /api/v2/Requests/recentlyRequested", m.wrap(m.recentlyRequested))
|
||||
mux.HandleFunc("GET /api/v1/Search/tv/info/{id}", m.wrap(m.tvInfoTVDB))
|
||||
mux.HandleFunc("GET /api/v1/Search/tv/{term}", m.wrap(m.tvSearch))
|
||||
mux.HandleFunc("GET /api/v1/Search/movie/{term}", m.wrap(m.movieSearch))
|
||||
mux.HandleFunc("GET /api/v1/Request/movie/info/{id}", m.wrap(m.movieInfo))
|
||||
mux.HandleFunc("POST /api/v2/Requests/tv", m.wrap(m.createTV))
|
||||
mux.HandleFunc("POST /api/v1/Request/tv", m.wrap(m.createTV))
|
||||
@@ -110,6 +117,8 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
|
||||
mockUser("u-1", "alice"), mockUser("u-2", "bob"),
|
||||
})))
|
||||
mux.HandleFunc("GET /api/v1/Identity/User/{id}", m.wrap(m.userGet))
|
||||
mux.HandleFunc("GET /api/v1/Vote", m.wrap(m.voteListFailure))
|
||||
mux.HandleFunc("GET /api/v1/Vote/{media}/{id}", m.wrap(m.voteGet))
|
||||
mux.HandleFunc("GET /api/v1/Radarr/Profiles", m.wrap(m.fixed(`[{"id":6,"name":"HD","weight":7,"enabled":false}]`)))
|
||||
mux.HandleFunc("GET /api/v2/Search/Genres/{media}", m.wrap(m.fixed(`[{"id":53,"name":"Thriller"}]`)))
|
||||
mux.HandleFunc("GET /api/v2/Search/Languages", m.wrap(m.fixed(`[{"iso_639_1":"ky","english_name":"??????","name":"Кыргызча"},{"iso_639_1":"zz","english_name":"??????","name":"??????"}]`)))
|
||||
@@ -419,7 +428,8 @@ func mockTVInfoV1() map[string]any {
|
||||
"title": "Breaking Bad", "name": "Breaking Bad",
|
||||
"overview": "A chemistry teacher turns to cooking meth.",
|
||||
"firstAired": "2008-01-20T00:00:00", "status": "Ended",
|
||||
"available": false, "requested": false, "fullyAvailable": false,
|
||||
// Live sends requestId 0 (not absent) on unrequested shows.
|
||||
"available": false, "requested": false, "requestId": 0, "fullyAvailable": false,
|
||||
"poster": "/ggFHVNu6YYI5L9pCfOacjizRGt.jpg",
|
||||
"banner": "/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
|
||||
"genre": []any{"Crime", "Drama", "Thriller"},
|
||||
@@ -454,7 +464,7 @@ func mockTVSearchHit() map[string]any {
|
||||
"imdbId": "tt0903747",
|
||||
"title": "Breaking Bad", "name": "Breaking Bad",
|
||||
"overview": "A chemistry teacher turns to cooking meth.",
|
||||
"firstAired": "2008-01-20T00:00:00",
|
||||
"firstAired": "2008-01-20T00:00:00", "siteRating": 9,
|
||||
"available": false, "requested": false,
|
||||
"poster": "/ggFHVNu6YYI5L9pCfOacjizRGt.jpg",
|
||||
"genre": []any{"Crime", "Drama"},
|
||||
@@ -462,6 +472,13 @@ func mockTVSearchHit() map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func mockMovieSearchHit() map[string]any {
|
||||
return map[string]any{
|
||||
"id": 348, "title": "Alien", "releaseDate": "1979-05-25T00:00:00",
|
||||
"voteAverage": 8.2, "voteCount": 15400,
|
||||
}
|
||||
}
|
||||
|
||||
func mockMovieRequest() map[string]any {
|
||||
return map[string]any{
|
||||
"id": 10, "requestId": 10, "theMovieDbId": 27205, "imdbId": "tt1375666",
|
||||
@@ -494,7 +511,12 @@ func mockTVChild() map[string]any {
|
||||
},
|
||||
},
|
||||
},
|
||||
"tvDbId": 81189, "externalProviderId": 1396, "imdbId": "tt0903747",
|
||||
// ChildRequests carries no top-level provider ids upstream —
|
||||
// they live on the embedded parent navigation property.
|
||||
"parentRequest": map[string]any{
|
||||
"id": 12, "tvDbId": 81189,
|
||||
"externalProviderId": 1396, "imdbId": "tt0903747",
|
||||
},
|
||||
"childFieldFuture": []any{1, 2},
|
||||
}
|
||||
}
|
||||
@@ -575,18 +597,27 @@ 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([]any{})(w, r)
|
||||
m.json(map[string]any{"collection": []any{}, "total": 1})(w, r)
|
||||
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{
|
||||
"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},
|
||||
@@ -598,9 +629,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)
|
||||
}
|
||||
|
||||
func (m *mockOmbi) tvChildren(w http.ResponseWriter, r *http.Request) {
|
||||
if r.PathValue("id") != "12" {
|
||||
m.jsonErr(w, http.StatusNotFound, "Parent request not found")
|
||||
return
|
||||
}
|
||||
m.json([]any{mockTVChild()})(w, r)
|
||||
}
|
||||
|
||||
func (m *mockOmbi) tvList(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
@@ -678,6 +741,26 @@ func (m *mockOmbi) tvSearch(w http.ResponseWriter, r *http.Request) {
|
||||
m.json([]any{mockTVSearchHit()})(w, r)
|
||||
}
|
||||
|
||||
func (m *mockOmbi) movieSearch(w http.ResponseWriter, r *http.Request) {
|
||||
m.json([]any{mockMovieSearchHit()})(w, r)
|
||||
}
|
||||
|
||||
func (m *mockOmbi) ratingsUnavailable(w http.ResponseWriter, r *http.Request) {
|
||||
m.jsonErr(w, http.StatusInternalServerError, "external ratings provider failed")
|
||||
}
|
||||
|
||||
func (m *mockOmbi) voteListFailure(w http.ResponseWriter, r *http.Request) {
|
||||
m.jsonErr(w, http.StatusInternalServerError, "vote view could not be generated")
|
||||
}
|
||||
|
||||
func (m *mockOmbi) voteGet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.PathValue("media") == "tv" {
|
||||
m.json([]any{})(w, r)
|
||||
return
|
||||
}
|
||||
m.jsonErr(w, http.StatusInternalServerError, "vote records could not be read")
|
||||
}
|
||||
|
||||
// tvBrowse is the v2 TMDB-keyed popular/anticipated/trending list —
|
||||
// members carry the provider id only in `id`.
|
||||
func (m *mockOmbi) tvBrowse(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -752,16 +835,27 @@ func (m *mockOmbi) recentlyRequested(w http.ResponseWriter, r *http.Request) {
|
||||
"requestDate": "2026-09-01T10:00:00",
|
||||
},
|
||||
map[string]any{
|
||||
// Live TV recent items put the provider id in `id` and
|
||||
// may omit requestId; target.id must not become 259032.
|
||||
"id": 259032, "requestId": 88, "type": 0,
|
||||
"title": "Seven Up!", "mediaId": "259032",
|
||||
// Live TV rows carry the CHILD request id in requestId —
|
||||
// for new-request children upstream persists the provider
|
||||
// id as the child PK — and the parent's externalProviderId
|
||||
// in mediaId. The parent request id is absent entirely.
|
||||
"requestId": 259032, "type": 0,
|
||||
"title": "Seven Up!", "mediaId": "118680",
|
||||
"userId": "u-1", "requestDate": "2026-09-02T10:00:00",
|
||||
},
|
||||
map[string]any{
|
||||
// A real (non-provider-shaped) child id — resolvable only
|
||||
// via the parent's embedded childRequests[].id.
|
||||
"requestId": 88, "type": 0,
|
||||
"title": "trigger-500 and something", "mediaId": "1396",
|
||||
"userId": "u-2", "requestDate": "2026-09-03T10:00:00",
|
||||
},
|
||||
map[string]any{
|
||||
// Unresolvable: no requestId, no parent match; a stray
|
||||
// provider-shaped `id` must never occupy target.id.
|
||||
"id": 111, "type": 0, "title": "Provider Only",
|
||||
"mediaId": "111", "userId": "u-1",
|
||||
"requestDate": "2026-09-03T10:00:00",
|
||||
"requestDate": "2026-09-04T10:00:00",
|
||||
},
|
||||
})(w, r)
|
||||
}
|
||||
@@ -863,6 +957,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"}]}`))
|
||||
}
|
||||
|
||||
func (m *mockOmbi) stats(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("from") == "" || r.URL.Query().Get("to") == "" {
|
||||
m.jsonErr(w, http.StatusInternalServerError, "Object reference not set to an instance of an object")
|
||||
return
|
||||
}
|
||||
m.fixed(`{"totalRequests":9,"totalMovieRequests":4,"totalTvRequests":5,"totalIssues":0,"completedRequestsMovies":2,"completedRequestsTv":1,"completedRequests":3}`)(w, r)
|
||||
}
|
||||
|
||||
func (m *mockOmbi) testcron(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
m.jsonErr(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
fields := strings.Fields(body.Expression)
|
||||
valid := len(fields) >= 6 && len(fields) <= 7 &&
|
||||
(fields[3] == "?") != (fields[5] == "?")
|
||||
if valid {
|
||||
m.fixed(`{"success":true}`)(w, r)
|
||||
return
|
||||
}
|
||||
m.fixed(fmt.Sprintf(`{"success":false,"message":%q}`, "CRON Expression "+body.Expression+" is not valid"))(w, r)
|
||||
}
|
||||
|
||||
func (m *mockOmbi) logsRead(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Write([]byte("2026-09-19 10:00:00 [INF] boot\n2026-09-19 10:01:00 [INF] tick\n"))
|
||||
}
|
||||
|
||||
// mockEnv returns the server env pointing at this mock in the
|
||||
// requested mode, plus all three bundles.
|
||||
func (m *mockOmbi) env() map[string]string {
|
||||
|
||||
@@ -113,7 +113,7 @@ type LibraryArgs struct {
|
||||
|
||||
// read_server
|
||||
type ServerArgs struct {
|
||||
Action string `json:"action"` // status|status_info|about|update_info|update_check|news|landing|features|stats|cron_validate
|
||||
Action string `json:"action"` // status|status_info|about|update_info|update_check|landing|features|stats|cron_validate
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Expression string `json:"expression,omitempty"`
|
||||
|
||||
@@ -111,6 +111,11 @@ func (o *op) discoverRequestedFallback(a *DiscoverArgs, media string) *ToolResul
|
||||
wantType = 0
|
||||
}
|
||||
items := make([]Media, 0, len(arr))
|
||||
type pendingTV struct {
|
||||
idx int
|
||||
key tvRecentKey
|
||||
}
|
||||
pending := []pendingTV{}
|
||||
for _, m := range arr {
|
||||
kind, ok := toInt(m["type"])
|
||||
if !ok || kind != wantType {
|
||||
@@ -132,7 +137,12 @@ func (o *op) discoverRequestedFallback(a *DiscoverArgs, media string) *ToolResul
|
||||
if id, ok := toInt(m["requestId"]); ok && id > 0 {
|
||||
kind := "movie"
|
||||
if media == "tv" {
|
||||
// Recent TV requestId is a child request id — resolve
|
||||
// to the parent below; tv_child stays as the truthful
|
||||
// fallback when no parent can be found.
|
||||
kind = "tv_child"
|
||||
pending = append(pending, pendingTV{
|
||||
len(items), tvRecentKey{requestID: id, mediaID: jstr(m, "mediaId")}})
|
||||
}
|
||||
it.RequestTargets = []OutTarget{{Kind: kind, ID: id}}
|
||||
}
|
||||
@@ -143,6 +153,18 @@ func (o *op) discoverRequestedFallback(a *DiscoverArgs, media string) *ToolResul
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
keys := make([]tvRecentKey, len(pending))
|
||||
for i, p := range pending {
|
||||
keys[i] = p.key
|
||||
}
|
||||
parents := o.resolveRecentTVParents(keys)
|
||||
for i, p := range pending {
|
||||
if pid := jint(parents[i], "id"); pid != nil && *pid > 0 {
|
||||
items[p.idx].RequestTargets = []OutTarget{{Kind: "tv_parent", ID: *pid}}
|
||||
}
|
||||
}
|
||||
}
|
||||
win, _ := localWindow(o, items, a.Page, "media")
|
||||
o.truncated = true
|
||||
o.warnf("requested browse fell back to Ombi's bounded recently-requested feed")
|
||||
|
||||
+38
-12
@@ -27,6 +27,34 @@ func logFileName(m map[string]any) string {
|
||||
return jstr(m, "fileName", "filename", "name")
|
||||
}
|
||||
|
||||
// logFileNames decodes a listing whose elements are bare file names on Ombi
|
||||
// 4.53.x. Older deployments that return objects with a name field remain
|
||||
// supported. Entries with no usable name are ignored.
|
||||
func (o *op) logFileNames(raw []byte) ([]string, *ToolResult) {
|
||||
var els []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &els); err != nil {
|
||||
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
|
||||
"upstream response was not a JSON array: "+sanitizeErr(err), false)
|
||||
}
|
||||
names := []string{}
|
||||
for _, el := range els {
|
||||
var name string
|
||||
if err := json.Unmarshal(el, &name); err == nil {
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal(el, &record); err == nil {
|
||||
if name := logFileName(record); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// read_logs — list sanitized log file IDs or read a bounded,
|
||||
// sanitized slice of a single log file.
|
||||
func handleLogs(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
|
||||
@@ -50,16 +78,10 @@ func (o *op) logsList() *ToolResult {
|
||||
if fail != nil {
|
||||
return fail
|
||||
}
|
||||
arr, fail := o.decodeArray(raw)
|
||||
names, fail := o.logFileNames(raw)
|
||||
if fail != nil {
|
||||
return fail
|
||||
}
|
||||
names := []string{}
|
||||
for _, m := range arr {
|
||||
if s := logFileName(m); s != "" {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
out := &Logs{Kind: "logs", Files: []LogFile{}}
|
||||
for _, n := range names {
|
||||
@@ -97,13 +119,13 @@ func (o *op) logsRead(a *LogsArgs) *ToolResult {
|
||||
if fail != nil {
|
||||
return fail
|
||||
}
|
||||
arr, fail := o.decodeArray(raw)
|
||||
names, fail := o.logFileNames(raw)
|
||||
if fail != nil {
|
||||
return fail
|
||||
}
|
||||
name := ""
|
||||
for _, m := range arr {
|
||||
if n := logFileName(m); n != "" && logFileID(n) == a.FileID {
|
||||
for _, n := range names {
|
||||
if logFileID(n) == a.FileID {
|
||||
name = n
|
||||
break
|
||||
}
|
||||
@@ -119,11 +141,15 @@ func (o *op) logsRead(a *LogsArgs) *ToolResult {
|
||||
raw = raw[:maxLogBodyBytes]
|
||||
o.truncated = true
|
||||
}
|
||||
text := sanitizeText(string(raw), maxLogBodyBytes)
|
||||
lines := strings.Split(text, "\n")
|
||||
// Sanitize individual lines so control characters and unbounded text do
|
||||
// not leak while preserving the upstream line boundaries for pagination.
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
for i := range lines {
|
||||
lines[i] = sanitizeText(lines[i], maxLogBodyBytes)
|
||||
}
|
||||
out := &Logs{Kind: "logs", Lines: []string{}, Offset: &offset}
|
||||
if offset < len(lines) {
|
||||
end := offset + limit
|
||||
|
||||
+108
-10
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"ombi-mcp/internal/ombi"
|
||||
)
|
||||
@@ -133,7 +135,10 @@ func (o *op) mediaDetails(a *mediaCallArgs) *ToolResult {
|
||||
// #7 request state overlay for tvdb
|
||||
if t.Media == "tv" && t.Provider == "tvdb" {
|
||||
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()
|
||||
o.overlayTVRequestState(&it, id, jstr(m, "imdbId"))
|
||||
}
|
||||
@@ -216,23 +221,116 @@ func (o *op) mediaRatings(a *mediaCallArgs) *ToolResult {
|
||||
raw, fail := o.call("GET",
|
||||
fmt.Sprintf("/api/v2/Search/ratings/%s/%s/%d", media, seg(a.Name), *a.Year),
|
||||
nil, nil)
|
||||
if fail != nil && !ratingsFallbackEligible(fail) {
|
||||
return fail
|
||||
}
|
||||
if fail == nil {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return o.fail("UPSTREAM_SCHEMA_MISMATCH",
|
||||
fmt.Sprintf("upstream response was not a JSON object: %s", sanitizeErr(err)), false)
|
||||
}
|
||||
if m != nil {
|
||||
if ratings := ratingReferences(m); len(ratings) > 0 {
|
||||
return o.ratingPage(media, a.Name, *a.Year, ratings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ombi 4.53.x still exposes the Rotten Tomatoes routes above, but their
|
||||
// private upstream endpoints have been removed. Fall back to rating
|
||||
// metadata already returned by Ombi's normal search providers rather than
|
||||
// turning that dependency failure into an unusable MCP action.
|
||||
page, fallbackFail := o.mediaRatingsFromSearch(media, a.Name, *a.Year)
|
||||
if fallbackFail != nil {
|
||||
if fail != nil {
|
||||
return fail
|
||||
}
|
||||
m, fail := o.decodeObject(raw)
|
||||
if fail != nil {
|
||||
return fail
|
||||
return fallbackFail
|
||||
}
|
||||
it := Media{Media: media, Identifiers: []Identifier{}, Title: a.Name, Year: a.Year}
|
||||
for k, v := range m {
|
||||
return o.ok(page)
|
||||
}
|
||||
|
||||
func ratingsFallbackEligible(fail *ToolResult) bool {
|
||||
if fail == nil || fail.Error == nil || fail.Error.HTTPStatus == nil {
|
||||
return false
|
||||
}
|
||||
status := *fail.Error.HTTPStatus
|
||||
return status == 404 || status >= 500
|
||||
}
|
||||
|
||||
func ratingReferences(m map[string]any) []Reference {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
refs := []Reference{}
|
||||
for _, k := range keys {
|
||||
v := m[k]
|
||||
if i, ok := toInt(v); ok {
|
||||
it.Ratings = append(it.Ratings, Reference{Name: k, Value: i})
|
||||
refs = append(refs, Reference{Name: k, Value: i})
|
||||
} else if s, ok := v.(string); ok && s != "" {
|
||||
it.Ratings = append(it.Ratings, Reference{Name: k, Value: s})
|
||||
refs = append(refs, Reference{Name: k, Value: s})
|
||||
}
|
||||
}
|
||||
return o.ok(&MediaPage{Kind: "media_page",
|
||||
Items: []Media{it}, Page: singlePage(1, "media")})
|
||||
return refs
|
||||
}
|
||||
|
||||
func (o *op) mediaRatingsFromSearch(media, name string, year int) (*MediaPage, *ToolResult) {
|
||||
raw, fail := o.call("GET", "/api/v1/Search/"+media+"/"+seg(name), nil, nil)
|
||||
if fail != nil {
|
||||
return nil, fail
|
||||
}
|
||||
arr, fail := o.decodeArray(raw)
|
||||
if fail != nil {
|
||||
return nil, fail
|
||||
}
|
||||
|
||||
for _, m := range arr {
|
||||
title := jstr(m, "title", "name")
|
||||
resultYear := yearOf(jstr(m, "releaseDate", "firstAired", "releaseYear"))
|
||||
if !strings.EqualFold(strings.TrimSpace(title), strings.TrimSpace(name)) ||
|
||||
resultYear == nil || *resultYear != year {
|
||||
continue
|
||||
}
|
||||
|
||||
refs := []Reference{}
|
||||
source := ""
|
||||
if media == "movie" {
|
||||
source = "TMDB"
|
||||
if v, ok := m["voteAverage"].(float64); ok {
|
||||
refs = append(refs, Reference{Name: "tmdb_vote_average", Value: v})
|
||||
}
|
||||
if v, ok := toInt(m["voteCount"]); ok {
|
||||
refs = append(refs, Reference{Name: "tmdb_vote_count", Value: v})
|
||||
}
|
||||
} else {
|
||||
source = "TVMaze"
|
||||
if v, ok := toInt(m["siteRating"]); ok {
|
||||
refs = append(refs, Reference{Name: "tvmaze_site_rating", Value: v})
|
||||
}
|
||||
}
|
||||
o.warnf("Ombi's Rotten Tomatoes ratings endpoint was unavailable; ratings were read from %s search metadata", source)
|
||||
if len(refs) == 0 {
|
||||
o.warnf("the matching %s search result did not include rating metadata", media)
|
||||
}
|
||||
return &MediaPage{Kind: "media_page", Items: []Media{{
|
||||
Media: media, Identifiers: []Identifier{}, Title: title, Year: resultYear, Ratings: refs,
|
||||
}}, Page: singlePage(1, "media")}, nil
|
||||
}
|
||||
|
||||
o.warnf("Ombi's Rotten Tomatoes ratings endpoint was unavailable and search returned no exact title/year match")
|
||||
return &MediaPage{Kind: "media_page", Items: []Media{{
|
||||
Media: media, Identifiers: []Identifier{}, Title: name, Year: &year, Ratings: []Reference{},
|
||||
}}, Page: singlePage(1, "media")}, nil
|
||||
}
|
||||
|
||||
func (o *op) ratingPage(media, title string, year int, ratings []Reference) *ToolResult {
|
||||
return o.ok(&MediaPage{Kind: "media_page", Items: []Media{{
|
||||
Media: media, Identifiers: []Identifier{}, Title: title, Year: &year, Ratings: ratings,
|
||||
}}, Page: singlePage(1, "media")})
|
||||
}
|
||||
|
||||
func (o *op) mediaStreaming(a *mediaCallArgs) *ToolResult {
|
||||
|
||||
@@ -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["mediaId"])
|
||||
r.Identifiers = addID(r.Identifiers, "imdb", m["imdbId"])
|
||||
// ChildRequests carries no top-level provider ids — they live
|
||||
// on the embedded parent record.
|
||||
if pr := jobj(m, "parentRequest"); pr != nil {
|
||||
r.Identifiers = addID(r.Identifiers, "tvdb", pr["tvDbId"])
|
||||
r.Identifiers = addID(r.Identifiers, "tmdb", pr["externalProviderId"])
|
||||
r.Identifiers = addID(r.Identifiers, "imdb", pr["imdbId"])
|
||||
}
|
||||
case "album":
|
||||
r.Identifiers = addID(r.Identifiers, "musicbrainz", m["foreignAlbumId"])
|
||||
r.Identifiers = addID(r.Identifiers, "musicbrainz", m["mediaId"])
|
||||
|
||||
@@ -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) {
|
||||
o := &op{}
|
||||
m := map[string]any{
|
||||
|
||||
@@ -51,7 +51,7 @@ var registry = []ToolDef{
|
||||
{"read_votes", "core", "Global vote list or votes on a request.", true, false, true, true, []string{"vote_page"}, handleVotes},
|
||||
{"read_users", "core", "Self, authorized user lookup, claims, online users, preference read.", true, false, true, true, []string{"user_page", "reference_page"}, handleUsers},
|
||||
{"read_library", "core", "Recent additions, calendar, artwork.", true, false, true, true, []string{"media_page", "calendar_page", "artwork_page"}, handleLibrary},
|
||||
{"read_server", "core", "Status, version, features, news, stats, cron validation.", true, false, true, true, []string{"metrics", "reference_page"}, handleServer},
|
||||
{"read_server", "core", "Status, version, features, stats, cron validation.", true, false, true, true, []string{"metrics", "reference_page"}, handleServer},
|
||||
{"read_integration", "core", "Saved ARR options and authorized media-server metadata.", true, false, true, true, []string{"reference_page", "user_page"}, handleIntegration},
|
||||
{"write_request_create", "core", "One media request or explicit collection request.", false, false, false, true, []string{"mutation"}, handleRequestCreate},
|
||||
{"write_request_subscribe", "core", "Subscribe/unsubscribe.", false, false, false, true, []string{"mutation"}, handleRequestSubscribe},
|
||||
|
||||
@@ -208,6 +208,11 @@ func (o *op) requestsRecent(a *RequestsListArgs) *ToolResult {
|
||||
return fail
|
||||
}
|
||||
items := []Request{}
|
||||
type pendingTV struct {
|
||||
idx int
|
||||
key tvRecentKey
|
||||
}
|
||||
pending := []pendingTV{}
|
||||
for _, m := range arr {
|
||||
code, label := o.requestTypeTwin(m["type"])
|
||||
kind := "movie"
|
||||
@@ -223,17 +228,54 @@ func (o *op) requestsRecent(a *RequestsListArgs) *ToolResult {
|
||||
kind = "movie"
|
||||
}
|
||||
r := o.projectRequest(m, kind)
|
||||
// RecentlyRequestedModel.RequestId is the Ombi request id.
|
||||
// Extra `id` on a TV payload is a provider id and must not
|
||||
// occupy target.id even when requestId is absent.
|
||||
if kind == "tv_parent" {
|
||||
// On TV rows RecentlyRequestedModel.requestId is the CHILD
|
||||
// request id (upstream persists the provider id as the
|
||||
// child PK for new-request children), never the parent id
|
||||
// `get` needs. Zero whatever requestId/`id` projected and
|
||||
// resolve the parent through the v1 parent list; the child
|
||||
// id stays available as an ombi_tv_child identifier.
|
||||
r.Target.ID = 0
|
||||
key := tvRecentKey{mediaID: jstr(m, "mediaId")}
|
||||
if id, ok := toInt(m["requestId"]); ok && id > 0 {
|
||||
key.requestID = id
|
||||
r.Identifiers = addID(r.Identifiers, "ombi_tv_child", id)
|
||||
}
|
||||
pending = append(pending, pendingTV{len(items), key})
|
||||
} else {
|
||||
// RecentlyRequestedModel.requestId is the request id for
|
||||
// movie/album rows; a stray provider `id` must not occupy
|
||||
// the target even when requestId is absent.
|
||||
if id, ok := toInt(m["requestId"]); ok && id > 0 {
|
||||
r.Target.ID = id
|
||||
} else {
|
||||
r.Target.ID = 0
|
||||
}
|
||||
}
|
||||
r.RequestedUserID = jstr(m, "userId")
|
||||
items = append(items, r)
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
keys := make([]tvRecentKey, len(pending))
|
||||
for i, p := range pending {
|
||||
keys[i] = p.key
|
||||
}
|
||||
parents := o.resolveRecentTVParents(keys)
|
||||
for i, p := range pending {
|
||||
r := &items[p.idx]
|
||||
parent, ok := parents[i]
|
||||
pid := jint(parent, "id")
|
||||
if !ok || pid == nil || *pid < 1 {
|
||||
o.warnf("recent tv item %q has no resolvable parent request; target.id left unknown",
|
||||
r.Title)
|
||||
continue
|
||||
}
|
||||
r.Target.ID = *pid
|
||||
r.Identifiers = addID(r.Identifiers, "tvdb", parent["tvDbId"])
|
||||
r.Identifiers = addID(r.Identifiers, "tmdb", parent["externalProviderId"])
|
||||
r.Identifiers = addID(r.Identifiers, "imdb", parent["imdbId"])
|
||||
}
|
||||
}
|
||||
win, pg := localWindow(o, items, a.Page, "requests")
|
||||
return o.ok(&RequestPage{Kind: "request_page", Items: win, Page: pg})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
return false, fail
|
||||
}
|
||||
arr, fail := o.decodeArray(raw)
|
||||
arr, fail := o.decodeTVParentPage(raw)
|
||||
if fail != nil {
|
||||
return false, fail
|
||||
}
|
||||
@@ -38,6 +40,146 @@ func (o *op) eachTVRequestParent(fn func(map[string]any) bool) (truncated bool,
|
||||
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 {
|
||||
|
||||
@@ -1796,18 +1796,6 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"const": "news"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1848,7 +1836,9 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"action"
|
||||
"action",
|
||||
"from",
|
||||
"to"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -1860,6 +1850,7 @@
|
||||
},
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "Quartz cron expression — 6 or 7 fields (seconds minutes hours day-of-month month day-of-week [year]); one of the two day fields must be ?",
|
||||
"minLength": 1,
|
||||
"maxLength": 200
|
||||
}
|
||||
|
||||
+13
-27
@@ -8,7 +8,7 @@ import (
|
||||
"ombi-mcp/internal/ombi"
|
||||
)
|
||||
|
||||
// read_server — status, version, features, news, stats and the
|
||||
// read_server — status, version, features, stats and the
|
||||
// administrator-gated cron validation. Families: metrics,
|
||||
// reference_page.
|
||||
func handleServer(ctx context.Context, env *Env, raw json.RawMessage) *ToolResult {
|
||||
@@ -28,8 +28,6 @@ func handleServer(ctx context.Context, env *Env, raw json.RawMessage) *ToolResul
|
||||
return o.serverUpdateInfo()
|
||||
case "update_check":
|
||||
return o.serverUpdateCheck()
|
||||
case "news":
|
||||
return o.serverNews()
|
||||
case "landing":
|
||||
return o.serverLanding()
|
||||
case "features":
|
||||
@@ -137,20 +135,6 @@ func (o *op) serverUpdateCheck() *ToolResult {
|
||||
Values: []Metric{metric("update_available", v, "instance", "")}})
|
||||
}
|
||||
|
||||
func (o *op) serverNews() *ToolResult {
|
||||
raw, fail := o.call("GET", "/api/v2/System/news", nil, nil)
|
||||
if fail != nil {
|
||||
return fail
|
||||
}
|
||||
items, fail := o.refArray(raw,
|
||||
[]string{"id"}, []string{"title", "name", "headline"}, "news")
|
||||
if fail != nil {
|
||||
return fail
|
||||
}
|
||||
return o.ok(&ReferencePage{Kind: "reference_page",
|
||||
Items: items, Page: singlePage(len(items), "references")})
|
||||
}
|
||||
|
||||
func (o *op) serverLanding() *ToolResult {
|
||||
raw, fail := o.call("GET", "/api/v1/LandingPage", nil, nil)
|
||||
if fail != nil {
|
||||
@@ -196,8 +180,14 @@ func (o *op) serverFeatures() *ToolResult {
|
||||
}
|
||||
|
||||
func (o *op) serverStats(a *ServerArgs) *ToolResult {
|
||||
// from <= to must be enforced by the server per the contract.
|
||||
if a.From != "" && a.To != "" {
|
||||
if !nonempty(a.From) || !nonempty(a.To) {
|
||||
field := "from"
|
||||
if nonempty(a.From) {
|
||||
field = "to"
|
||||
}
|
||||
return o.invalid(field, "stats requires both from and to as RFC3339 date-time values")
|
||||
}
|
||||
// from <= to is enforced before the upstream call.
|
||||
f, ferr := time.Parse(time.RFC3339, a.From)
|
||||
t, terr := time.Parse(time.RFC3339, a.To)
|
||||
if ferr != nil || terr != nil {
|
||||
@@ -206,14 +196,7 @@ func (o *op) serverStats(a *ServerArgs) *ToolResult {
|
||||
if f.After(t) {
|
||||
return o.invalid("from", "from must be on or before to")
|
||||
}
|
||||
}
|
||||
q := map[string]string{}
|
||||
if a.From != "" {
|
||||
q["from"] = a.From
|
||||
}
|
||||
if a.To != "" {
|
||||
q["to"] = a.To
|
||||
}
|
||||
q := map[string]string{"from": a.From, "to": a.To}
|
||||
raw, fail := o.call("GET", "/api/v1/Stats", q, nil)
|
||||
if fail != nil {
|
||||
return fail
|
||||
@@ -256,5 +239,8 @@ func (o *op) serverCronValidate(a *ServerArgs) *ToolResult {
|
||||
if s := jstr(m, "message"); s != "" {
|
||||
vals = append(vals, metric("message", s, "instance", ""))
|
||||
}
|
||||
if valid, ok := m["success"].(bool); ok && !valid {
|
||||
o.warnf("Ombi validates Quartz cron syntax (6-7 fields: seconds minutes hours day-of-month month day-of-week [year]); use ? for the unused day-of-month/day-of-week field")
|
||||
}
|
||||
return o.ok(&Metrics{Kind: "metrics", Values: vals})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user