Author SHA1 Message Date
gronod 80e01253a2 Fix #10, #27 and #28 from the read-tools sweep
Build and publish / Test and build (linux) (pull_request) Canceled after 0s
Build and publish / Test and build (windows) (pull_request) Canceled after 0s
Build and publish / Build and publish Docker image (pull_request) Canceled after 0s
Build and publish / Test and build (darwin) (pull_request) Canceled after 40s
Build and publish / Test and build (darwin) (push) Successful in 2m9s
Build and publish / Test and build (linux) (push) Successful in 2m34s
Build and publish / Test and build (windows) (push) Successful in 3m11s
Build and publish / Build and publish Docker image (push) Successful in 1m55s
v2 request lists sent the RAML example sort field requestDate; Ombi
looks up RequestedDate and NullReferenceException'd every non-empty
page. Browse now streams TV popular/most-watched payloads and skips
the hydrated seasonRequests graph that blew the 8 MiB read budget.
provider_summary treats an empty upstream body as an empty group_page.
2026-09-19 20:59:57 +01:00
gronod 958006cff2 Document #12 vote read upstream failures
Build and publish / Test and build (darwin) (pull_request) Successful in 1m50s
Build and publish / Test and build (linux) (pull_request) Successful in 2m21s
Build and publish / Test and build (windows) (pull_request) Successful in 3m6s
Build and publish / Build and publish Docker image (pull_request) Successful in 1m53s
Build and publish / Test and build (darwin) (push) Successful in 2m9s
Build and publish / Test and build (linux) (push) Successful in 2m31s
Build and publish / Test and build (windows) (push) Successful in 3m7s
Build and publish / Build and publish Docker image (push) Successful in 2m15s
2026-09-19 18:50:24 +01:00
gronod 7658bc8f39 Fix #8: fall back to search ratings metadata 2026-09-19 18:48:47 +01:00
13 changed files with 652 additions and 38 deletions
+5 -5
View File
@@ -95,7 +95,7 @@ The [input catalogue](03-input-schemas.md) defines every parameter, required fie
| `welcome_email` | 8 | | | |
Remaining numeric enums are unresolved gaps and stay numeric pending verification: `VoteType [0,1]` (labels unverified), `RequestSource`, `RequestLimitType [0,1,2]`, and the legacy `orderType`/`statusType`/`availabilityType` filter ints. Search response IDs must retain their source/provider namespace, including multi-search TV TMDB IDs.
5. A TV parent is a show record; a TV child is an individual request under it. `read_requests.list(media=tv)` uses v2 child pages. Parent detail and children enumeration use v1. A compact projection of v2 children is preferable to secretly swapping in `tvlite` parent results (the v2 list contract is preserved; upstream 500 errors surface natively). Return `parent_request_id` alongside child IDs when present. `read_requests.search` for TV uses v1 `Request/tv/search/{term}` but falls back to a bounded v1 parent scan if it fails, compensating for an upstream LINQ bug.
5. A TV parent is a show record; a TV child is an individual request under it. `read_requests.list(media=tv)` uses v2 child pages. Parent detail and children enumeration use v1. A compact projection of v2 children is preferable to secretly swapping in `tvlite` parent results (the v2 list contract is preserved). Return `parent_request_id` alongside child IDs when present. `read_requests.search` for TV uses v1 `Request/tv/search/{term}` but falls back to a bounded v1 parent scan if it fails, compensating for an upstream LINQ bug. v2 list `{sort}` is the C# property `RequestedDate` (not the RAML example `requestDate`, which TypeDescriptor cannot resolve and which 500s every non-empty page).
6. Parameter names such as `currentPosition`, `position`, `skip`, `count`, `take` and `amountToLoad` are mapped exactly per ledger. Offsets are zero-based by this MCP contract; the adapter must verify ambiguous upstream paging behaviour. Requests use count then position; issue summary uses position then take; issue list uses take then skip. Do not reverse these pairs.
7. Array responses without server pagination are sliced locally only within a bounded fetched response. Mark pagination as local and total unknown unless the complete collection was obtained. A result-size cap is a truncation warning, not a fabricated server total or a promise that the next page exists.
8. `format: date-time` and cross-field comparisons must be enforced by the server, not assumed from a client's validator. Stats requires `from <= to` when both are supplied. Strings must contain non-whitespace text where used as queries/comments. Reject duplicate season numbers and duplicate episode numbers; impose a maximum of 2,000 selected episodes per call in addition to per-array limits.
@@ -111,14 +111,14 @@ 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.
## Requests and quotas
Use v2 list/status routes, always with sort and page segments. `status` defaults `all`; `sort_direction` maps to the `{sortOrder}` segment (`asc`/`desc`). Public `sort.field=request_date` maps to the documented example `requestDate`; no speculative sort fields are published. `all` means the base route, not an `/all/` segment. Album lacks an unavailable-status route, so that combination fails schema validation. The misspelled movie `availble` route is an explicitly gated compatibility alias, not the primary path.
Use v2 list/status routes, always with sort and page segments. `status` defaults `all`; `sort_direction` maps to the `{sortOrder}` segment (`asc`/`desc`). Public `sort.field=request_date` maps to Ombi's `RequestedDate` property (the RAML example `requestDate` does not match and 500s on non-empty pages). 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; 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.
@@ -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.
@@ -226,7 +226,7 @@ Locked decision D3 keeps snake_case MCP field names; the Go adapter translates e
| `is_4k` | create/moderate/reprocess | `is4kRequest` body / `is4K` body / `{is4K}` path |
| `request_type` | `write_issue_create`, `write_request_reprocess` | `requestType` body int / `{type}` path int (T1 map) |
| `status` | `read_issues`, `write_issue_manage` | `{status}` path int / `status` body int (T2 map) |
| `sort_direction` | `read_requests.list` | `{sortOrder}` path `asc`/`desc` (sort=`requestDate`) |
| `sort_direction` | `read_requests.list` | `{sortOrder}` path `asc`/`desc` (sort=`RequestedDate`) |
| `season_numbers` | `write_request_create` tv season mode | adapter expansion → `seasons[].episodes` |
| `on_behalf_user_id` | `write_request_create` | `requestOnBehalf` body (**Verify**: id vs username) |
| `requested_by_alias` | `write_request_create.album` | `requestedByAlias` body |
+1 -1
View File
@@ -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
{
+2 -2
View File
@@ -16,10 +16,10 @@ 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. |
| 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. Browse streams the upstream array and discards `seasonRequests` (Ombi hydrates full episode trees on popular/most-watched when hiding available titles). Map cast/crew into credits; title-specific streaming into providers; rating fields into named rating references. Never claim a global provider catalogue is a title's availability. Collections keep their own collection identity and returned members. |
| request_page | Map the Ombi request id to target kind and ID: prefer `requestId` over `id`. v2 TV list items are children and `parentRequestId` is preserved; v1 parent records stay parents; child provider ids live on the embedded `parentRequest` record. On `recent`, `RecentlyRequestedModel.requestId` is the request id for movie/album, but on TV rows it is a *child* request id (upstream builds recent TV rows from child requests and persists the provider id as the child PK for new-request children). The `tv_parent` target id is therefore resolved through a bounded v1 parent scan — child-id match first, then `tvDbId`/`externalProviderId` fallback — the child id is emitted as an `ombi_tv_child` identifier, provider values land in `identifiers` (`mediaId`, `tvDbId`, `externalProviderId`), and unresolvable rows emit `target.id` 0 with a warning. A provider-shaped value is never the target. Include standard and 4K state separately. Never infer one combined lifecycle status when booleans disagree. |
| issue_page | Project writable/display fields plus IDs/timestamps. Wire `resovledDate` maps to `resolved_date` without changing upstream spelling. Omit nested user objects and comments unless requested separately. |
| group_page | v2 issue summaries are provider groups. Count and page units describe groups, not individual issues. Truncate nested issues with a warning. |
| group_page | v2 issue summaries are provider groups. Count and page units describe groups, not individual issues. Truncate nested issues with a warning. An empty, null, or `[]` body from `provider_summary` is an empty page, not a schema mismatch. |
| comment_page | Preserve comment text and authorized author identifier, omit full user graph. |
| vote_page | Preserve numeric VoteType until verified; totals only if supplied or completely computed from an authorized complete set. No inference of the caller's own vote without identity evidence. |
| user_page | Only ID, username/alias and non-secret language/country/online state. Permission-limited user visibility applies even to nested source objects. |
+4 -4
View File
@@ -234,8 +234,8 @@ The body/response columns describe the **upstream** schema, not a promise to pas
| 216 | [GET `/api/v2/Search/artist/request/{requestId}`](../api/raml/api.raml#L3949) | D | `read_media / by_request` | path `requestId`:integer required | 200: [ArtistInformation](../api/raml/types/Ombi.Core.Models.Search.V2.Music.ArtistInformation.raml) | Request-ID namespace requires verification. MCP `media=album` maps to this literal `artist` route segment. |
| 217 | [GET `/api/v2/Search/artist/album/{albumId}`](../api/raml/api.raml#L3976) | D | `read_media / details` | path `albumId`:string required | 200: [ReleaseGroup](../api/raml/types/Ombi.Core.Models.Search.V2.Music.ReleaseGroup.raml) | — |
| 218 | [GET `/api/v2/Search/releasegroupart/{musicBrainzId}`](../api/raml/api.raml#L4003) | D | `read_library / album_art` | path `musicBrainzId`:string required | 200: [AlbumArt](../api/raml/types/Ombi.Core.Models.Search.V2.Music.AlbumArt.raml) | — |
| 219 | [GET `/api/v2/Search/ratings/movie/{name}/{year}`](../api/raml/api.raml#L4030) | D | `read_media / ratings` | path `name`:string required; path `year`:integer required | 200: [MovieRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.MovieRatings.raml) | — |
| 220 | [GET `/api/v2/Search/ratings/tv/{name}/{year}`](../api/raml/api.raml#L4060) | D | `read_media / ratings` | path `name`:string required; path `year`:integer required | 200: [TvRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.TvRatings.raml) | — |
| 219 | [GET `/api/v2/Search/ratings/movie/{name}/{year}`](../api/raml/api.raml#L4030) | D | `read_media / ratings` | path `name`:string required; path `year`:integer required | 200: [MovieRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.MovieRatings.raml) | Ombi 4.53.x depends on removed Rotten Tomatoes private endpoints; on 404/5xx/empty results, fall back to an exact title/year v1 movie search and label TMDB rating fields. |
| 220 | [GET `/api/v2/Search/ratings/tv/{name}/{year}`](../api/raml/api.raml#L4060) | D | `read_media / ratings` | path `name`:string required; path `year`:integer required | 200: [TvRatings](../api/raml/types/Ombi.Api.External.ExternalApis.RottenTomatoes.Models.TvRatings.raml) | Ombi 4.53.x depends on removed Rotten Tomatoes private endpoints; on 404/5xx/empty results, fall back to an exact title/year v1 TV search and label TVMaze rating fields. |
| 221 | [GET `/api/v2/Search/stream/movie/{movieDbId}`](../api/raml/api.raml#L4090) | D | `read_media / streaming` | path `movieDBId`:integer required | 200: array&lt;[StreamingData](../api/raml/types/Ombi.Core.Models.Search.V2.StreamingData.raml)&gt; | — |
| 222 | [GET `/api/v2/Search/stream/tv/{movieDbId}`](../api/raml/api.raml#L4120) | D | `read_media / streaming` | path `movieDbId`:integer required | 200: array&lt;[StreamingData](../api/raml/types/Ombi.Core.Models.Search.V2.StreamingData.raml)&gt; | — |
| 223 | [GET `/api/v1/Search/movie/{searchTerm}`](../api/raml/api.raml#L4150) | D | `read_search / text` | path `searchTerm`:unspecified required | 200: array&lt;[SearchMovieViewModel](../api/raml/types/Ombi.Core.Models.Search.SearchMovieViewModel.raml)&gt; | — |
@@ -383,13 +383,13 @@ The body/response columns describe the **upstream** schema, not a promise to pas
| 365 | [POST `/api/v1/Token/requirePassword`](../api/raml/api.raml#L6464) | I | `upstream authentication adapter` | body [UserAuthModel](../api/raml/types/Ombi.Models.UserAuthModel.raml) | 200: boolean | Not model-callable; JWT-primary auth adapter (`OMBI_AUTH_MODE=jwt`); login/refresh semantics per `01-authentication.md`; Bearer-on-all-routes remains Verify. |
| 366 | [POST `/api/v1/Token/header_auth`](../api/raml/api.raml#L6482) | I | `upstream authentication adapter` | none documented | 200: body unspecified | Not model-callable; JWT-primary auth adapter (`OMBI_AUTH_MODE=jwt`); login/refresh semantics per `01-authentication.md`; Bearer-on-all-routes remains Verify. |
| 367 | [GET `/api/v1/Update`](../api/raml/api.raml#L6494) | D | `read_server / update_info` | none documented | 200: [UpdateModel](../api/raml/types/Ombi.Core.Processor.UpdateModel.raml) | — |
| 368 | [GET `/api/v1/Vote`](../api/raml/api.raml#L6503) | D | `read_votes / list` | none documented | 200: array&lt;[VoteViewModel](../api/raml/types/Ombi.Core.Models.UI.VoteViewModel.raml)&gt; | — |
| 368 | [GET `/api/v1/Vote`](../api/raml/api.raml#L6503) | D | `read_votes / list` | none documented | 200: array&lt;[VoteViewModel](../api/raml/types/Ombi.Core.Models.UI.VoteViewModel.raml)&gt; | Ombi 4.53.10 can return HTTP 500 for existing instance data; no lossless alternate read route exists, so preserve `UPSTREAM_REJECTED`. |
| 369 | [POST `/api/v1/Vote/up/movie/{requestId}`](../api/raml/api.raml#L6515) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 370 | [POST `/api/v1/Vote/up/tv/{requestId}`](../api/raml/api.raml#L6530) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 371 | [POST `/api/v1/Vote/up/album/{requestId}`](../api/raml/api.raml#L6545) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 372 | [POST `/api/v1/Vote/down/movie/{requestId}`](../api/raml/api.raml#L6560) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 373 | [POST `/api/v1/Vote/down/tv/{requestId}`](../api/raml/api.raml#L6575) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 374 | [POST `/api/v1/Vote/down/album/{requestId}`](../api/raml/api.raml#L6590) | D | `write_vote` | path `requestId`:integer required | 200: [VoteEngineResult](../api/raml/types/Ombi.Core.Models.VoteEngineResult.raml) | — |
| 375 | [GET `/api/v1/Vote/movie/{requestId}`](../api/raml/api.raml#L6605) | D | `read_votes / get` | path `requestId`:integer required | 200: array&lt;[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)&gt; | — |
| 375 | [GET `/api/v1/Vote/movie/{requestId}`](../api/raml/api.raml#L6605) | D | `read_votes / get` | path `requestId`:integer required | 200: array&lt;[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)&gt; | Ombi 4.53.10 can return HTTP 500 for existing instance data; no lossless alternate read route exists, so preserve `UPSTREAM_REJECTED`. |
| 376 | [GET `/api/v1/Vote/music/{requestId}`](../api/raml/api.raml#L6621) | D | `read_votes / get` | path `requestId`:integer required | 200: array&lt;[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)&gt; | — |
| 377 | [GET `/api/v1/Vote/tv/{requestId}`](../api/raml/api.raml#L6637) | D | `read_votes / get` | path `requestId`:integer required | 200: array&lt;[Votes](../api/raml/types/Ombi.Store.Entities.Votes.raml)&gt; | — |
+13 -2
View File
@@ -56,7 +56,7 @@ These are future checks, not claims that an implementation was written or tested
- An empty object must not satisfy a request-creation, issue-creation, moderation or delete schema. Optional defaults must not trigger unrelated conditional requirements.
- Movie, TVDB TV, TMDB TV, album and collection creation must send the exact wire body and method. TV has no is4kRequest, album has no requestOnBehalf, and collection has no invented body.
- Denial uses PUT for all media. Similar and actor searches use POST. Lidarr Metadata uses POST. User detail uses Identity/User/{id}. No POST RequestRetry is emitted.
- Request list rejects album+unavailable, preserves TV child identity, maps request_date to requestDate and verifies local versus upstream pagination metadata.
- Request list rejects album+unavailable, preserves TV child identity, maps request_date to RequestedDate and verifies local versus upstream pagination metadata.
- Reject empty explicit episode lists, duplicate seasons/episodes, ambiguous providers, irrelevant action properties and overflowing request budgets before upstream calls.
- Check every method/path pair against the ledger, including spelling/case and request/response types. Generated brace expansion must never add routes.
- Omitted `is_4k`/`status`/`sort_direction` resolve to documented defaults without triggering conditional requirements.
@@ -302,7 +302,7 @@ The gated live suite is committed and ready; run it with a sourced `.env`. It ad
## M5 Findings — Request state and list routes (#7, #10)
- `#7`: `GET /api/v1/Search/tv/info/{tvdbId}` (TVMaze-backed info route) upstream does not set request state flags accurately, returning `requested: false` regardless of truth. The adapter patches this by performing a bounded `GET /api/v1/Request/tv` parent scan and overlaying per-episode availability and request states.
- `#10`: `GET /api/v2/Requests/{movie,tv,album}/...` (v2 lists) encounter a per-row `NullReferenceException` on non-empty results (observed on Ombi 4.53.10). The adapter surfaces this as `UPSTREAM_REJECTED` rather than silently replacing it with v1 `tvlite` payloads, preserving the expected v2 child-page contract.
- `#10`: `GET /api/v2/Requests/{movie,tv,album}/...` (v2 lists) 500'd on every non-empty page because the adapter sent the RAML example sort field `requestDate`. Ombi resolves `{sort}` through `TypeDescriptor.GetProperties(...).Find(sortProperty, true)` against `RequestedDate`; a miss leaves `prop` null and `prop.GetValue(x)` throws `NullReferenceException`. Empty `pending` pages never called `GetValue`, which is why they appeared to work. The adapter now sends `RequestedDate`.
- `#10` search fallback: `GET /api/v1/Request/tv/search/{term}` on 4.53.10 suffers from a LINQ translation bug (upstream Ombi-app/Ombi#5420, fixed by #5421). The adapter gracefully falls back to a bounded v1 parent scan filtering locally on the term if the primary search route fails.
## M6 Findings — Integration options and server-id discovery (#16, #17, #18, #20)
@@ -324,3 +324,14 @@ The gated live suite is committed and ready; run it with a sourced `.env`. It ad
- `#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.
## Read-tools sweep findings (#10 remainder, #27, #28)
- `#10` remainder: the v2 list 500s were not an upstream per-row serializer bug. Ombi looks up `{sort}` as a C# property name (`RequestedDate`); `requestDate` misses, `prop` is null, and `GetValue` throws on the first row. Pending pages were empty so they never threw. The adapter now sends `RequestedDate`.
- `#27`: `read_discover browse` TV `popular`/`most_watched` exceeded the 8 MiB read budget because Ombi hydrates `seasonRequests` for every show when `HideAvailableFromDiscover` is on. Browse now streams the JSON array, skips `seasonRequests` while tokenizing, and uses a 64 MiB safety cap.
- `#28`: `GET /api/v2/Issues/details/{providerId}` returns an empty body when the provider has no issues. `provider_summary` treats empty/`null`/`[]` as an empty `group_page` instead of a decode failure.
+182
View File
@@ -6,6 +6,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"testing"
)
@@ -273,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())
@@ -2200,3 +2281,104 @@ func TestM6ServerIdentityDiscoveryAndMediaServer(t *testing.T) {
t.Errorf("POST /api/v1/Emby/Library was not called")
}
}
func TestReadRequestsListUsesRequestedDateSort(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": "list", "media": "movie", "status": "available",
})
requireOK(t, out)
found := false
for _, r := range mock.requests() {
if r.Method == "GET" && strings.Contains(r.Path, "/api/v2/Requests/movie/") &&
strings.Contains(r.Path, "/RequestedDate/") {
found = true
if strings.Contains(r.Path, "/requestDate/") {
t.Errorf("legacy requestDate sort segment still present: %s", r.Path)
}
}
}
if !found {
t.Fatal("expected v2 movie list to use RequestedDate sort segment")
}
}
func TestReadDiscoverTVMostWatchedSkimsHugeSeasonGraph(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_discover", map[string]any{
"action": "browse", "media": "tv", "category": "most_watched",
"page": map[string]any{"limit": 3},
})
data := requireOK(t, out)
var page struct {
Items []struct {
Title string `json:"title"`
Identifiers []struct {
Namespace string `json:"namespace"`
Value string `json:"value"`
} `json:"identifiers"`
Seasons []any `json:"seasons"`
} `json:"items"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
if len(page.Items) != 1 || page.Items[0].Title != "Reacher" {
t.Fatalf("unexpected browse page: %s", data)
}
if len(page.Items[0].Seasons) != 0 {
t.Errorf("seasonRequests leaked into projection: %+v", page.Items[0].Seasons)
}
ids := map[string]string{}
for _, id := range page.Items[0].Identifiers {
ids[id.Namespace] = id.Value
}
if ids["tmdb"] != "1668" {
t.Errorf("identifiers = %v, want tmdb 1668", ids)
}
assertNoLeak(t, out.Raw)
}
func TestReadIssuesProviderSummaryEmptyBody(t *testing.T) {
mock := newMockOmbi(t, "jwt")
c := spawnServer(t, mock.env())
c.handshake(t)
out := c.callTool(t, "read_issues", map[string]any{
"action": "provider_summary", "provider_id": "348",
})
data := requireOK(t, out)
var page struct {
Kind string `json:"kind"`
Items []any `json:"items"`
Page struct {
Returned int `json:"returned"`
Total *int `json:"total"`
} `json:"page"`
}
if err := json.Unmarshal(data, &page); err != nil {
t.Fatalf("decode: %v\n%s", err, data)
}
if page.Kind != "group_page" || len(page.Items) != 0 {
t.Fatalf("empty provider_summary must be an empty group_page: %s", data)
}
if page.Page.Returned != 0 {
t.Errorf("returned = %d, want 0", page.Page.Returned)
}
outHit := c.callTool(t, "read_issues", map[string]any{
"action": "provider_summary", "provider_id": "tt0089826",
})
dataHit := requireOK(t, outHit)
if !strings.Contains(string(dataHit), `"The Equalizer"`) {
t.Fatalf("non-empty provider_summary: %s", dataHit)
}
assertNoLeak(t, out.Raw)
assertNoLeak(t, outHit.Raw)
}
+76 -4
View File
@@ -68,8 +68,10 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
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/Requests/movie/{amt}/{pos}/RequestedDate/{order}", m.wrap(m.movieList))
mux.HandleFunc("GET /api/v2/Requests/tv/{amt}/{pos}/RequestedDate/{order}", m.wrap(m.tvList))
mux.HandleFunc("GET /api/v2/Requests/movie/{status}/{amt}/{pos}/RequestedDate/{order}", m.wrap(m.movieList))
mux.HandleFunc("GET /api/v2/Requests/tv/{status}/{amt}/{pos}/RequestedDate/{order}", m.wrap(m.tvList))
mux.HandleFunc("GET /api/v2/Search/movie/{id}", m.wrap(m.movieDetails))
mux.HandleFunc("GET /api/v2/Search/movie/collection/{id}", m.wrap(m.movieCollection))
mux.HandleFunc("GET /api/v2/Search/movie/requested/{pos}/{amt}", m.wrap(m.emptyBrowse))
@@ -77,15 +79,19 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
mux.HandleFunc("GET /api/v2/Search/tv/{id}", m.wrap(m.tvDetailsTVDB))
mux.HandleFunc("GET /api/v2/Search/tv/popular/{pos}/{amt}", m.wrap(m.tvBrowse))
mux.HandleFunc("GET /api/v2/Search/tv/anticipated/{pos}/{amt}", m.wrap(m.tvBrowse))
mux.HandleFunc("GET /api/v2/Search/tv/mostwatched/{pos}/{amt}", m.wrap(m.tvBrowseHuge))
mux.HandleFunc("GET /api/v2/Search/tv/trending/{pos}/{amt}", m.wrap(m.tvBrowse))
mux.HandleFunc("GET /api/v2/Issues/details/{providerId}", m.wrap(m.issueProviderSummary))
mux.HandleFunc("GET /api/v2/Search/tv/requested/{pos}/{amt}", m.wrap(m.emptyBrowse))
mux.HandleFunc("GET /api/v2/Search/actor/{id}/movie", m.wrap(m.actorMovieCredits))
mux.HandleFunc("GET /api/v2/Search/actor/{id}/tv", m.wrap(m.actorTVCredits))
mux.HandleFunc("POST /api/v2/Search/advancedSearch/movie/{pos}/{amt}", m.wrap(m.advancedMovie))
mux.HandleFunc("POST /api/v2/Search/multi/{term}", m.wrap(m.multiSearch))
mux.HandleFunc("GET /api/v2/Search/ratings/{media}/{name}/{year}", m.wrap(m.ratingsUnavailable))
mux.HandleFunc("GET /api/v2/Requests/recentlyRequested", m.wrap(m.recentlyRequested))
mux.HandleFunc("GET /api/v1/Search/tv/info/{id}", m.wrap(m.tvInfoTVDB))
mux.HandleFunc("GET /api/v1/Search/tv/{term}", m.wrap(m.tvSearch))
mux.HandleFunc("GET /api/v1/Search/movie/{term}", m.wrap(m.movieSearch))
mux.HandleFunc("GET /api/v1/Request/movie/info/{id}", m.wrap(m.movieInfo))
mux.HandleFunc("POST /api/v2/Requests/tv", m.wrap(m.createTV))
mux.HandleFunc("POST /api/v1/Request/tv", m.wrap(m.createTV))
@@ -115,6 +121,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":"??????"}]`)))
@@ -460,14 +468,21 @@ 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",
"available": false, "requested": false,
"firstAired": "2008-01-20T00:00:00", "siteRating": 9,
"available": false, "requested": false,
"poster": "/ggFHVNu6YYI5L9pCfOacjizRGt.jpg",
"genre": []any{"Crime", "Drama"},
"extraSearchField": []any{"z"},
}
}
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",
@@ -730,6 +745,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) {
@@ -742,6 +777,43 @@ func (m *mockOmbi) tvBrowse(w http.ResponseWriter, r *http.Request) {
})(w, r)
}
// tvBrowseHuge emulates HideAvailableFromDiscover season hydration:
// a compact title plus a seasonRequests graph larger than the
// default 8 MiB read budget (#27).
func (m *mockOmbi) tvBrowseHuge(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]any{
map[string]any{
"id": 1668,
"title": "Reacher",
"overview": "A former military policeman.",
"posterPath": "/reacher.jpg",
"seasonRequests": []any{
map[string]any{
"seasonNumber": 1,
"overview": strings.Repeat("x", 9<<20),
},
},
},
})
}
func (m *mockOmbi) issueProviderSummary(w http.ResponseWriter, r *http.Request) {
if r.PathValue("providerId") == "348" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
return
}
m.json(map[string]any{
"providerId": r.PathValue("providerId"),
"title": "The Equalizer",
"count": 1,
"issues": []any{
map[string]any{"id": 1, "title": "Audio missing", "status": 0},
},
})(w, r)
}
func (m *mockOmbi) movieCollection(w http.ResponseWriter, r *http.Request) {
m.json(map[string]any{
"id": 8091, "name": "Alien Collection", "overview": "In space…",
+174
View File
@@ -21,6 +21,20 @@ import (
// into an unbounded entity graph.
const maxUpstreamBody = 8 << 20 // 8 MiB
// maxBrowseBody is the read cap for server-paged discover browse
// routes. Ombi's TV popular/mostwatched payloads embed full
// seasonRequests trees when HideAvailableFromDiscover is enabled,
// so three long-running shows routinely exceed 8 MiB. The extra
// budget is paired with a streaming skim that discards those
// nested graphs before they become Go values.
const maxBrowseBody = 64 << 20 // 64 MiB
// browseSkipKeys are nested graphs on SearchTvShowViewModel that
// the browse projection never emits.
var browseSkipKeys = map[string]bool{
"seasonRequests": true,
}
// maxSanitizedMsg bounds upstream-derived error text.
const maxSanitizedMsg = 300
@@ -67,6 +81,166 @@ func (o *op) call(method, path string, query map[string]string, body any) ([]byt
return raw, nil
}
// callSkimArray GETs an upstream JSON array and decodes each object
// while skipping keys in skip. budget is the maximum number of
// response bytes that may be consumed.
func (o *op) callSkimArray(path string, budget int64, skip map[string]bool) ([]map[string]any, *ToolResult) {
resp, err := o.env.Upstream.Do(o.ctx, "GET", path, nil, nil)
if err != nil {
return nil, o.transportErr(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamBody+1))
if err != nil {
return nil, o.transportErr(err)
}
return nil, o.httpErr(resp, raw)
}
cr := &countingReader{r: io.LimitReader(resp.Body, budget+1)}
arr, fail := o.decodeSkimArray(cr, budget, skip)
if fail != nil {
return nil, fail
}
return arr, nil
}
type countingReader struct {
r io.Reader
n int64
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += int64(n)
return n, err
}
func (o *op) decodeSkimArray(r io.Reader, budget int64, skip map[string]bool) ([]map[string]any, *ToolResult) {
cr, _ := r.(*countingReader)
if cr == nil {
cr = &countingReader{r: r}
r = cr
}
dec := json.NewDecoder(r)
tok, err := dec.Token()
if err != nil {
if cr.n > budget {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
"upstream response exceeded the read budget", false)
}
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
fmt.Sprintf("upstream response was not a JSON array: %s", sanitizeErr(err)), false)
}
if delim, ok := tok.(json.Delim); !ok || delim != '[' {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
"upstream response was not a JSON array", false)
}
out := []map[string]any{}
for dec.More() {
if cr.n > budget {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
"upstream response exceeded the read budget", false)
}
m, err := decodeSkimObject(dec, skip)
if err != nil {
if cr.n > budget {
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
"upstream response exceeded the read budget", false)
}
return nil, o.fail("UPSTREAM_SCHEMA_MISMATCH",
fmt.Sprintf("upstream response did not match the expected shape: %s", sanitizeErr(err)), false)
}
out = append(out, m)
if len(out) >= maxRecords {
o.warnf("result capped at %d records", maxRecords)
o.truncated = true
break
}
}
return out, nil
}
func decodeSkimObject(dec *json.Decoder, skip map[string]bool) (map[string]any, error) {
tok, err := dec.Token()
if err != nil {
return nil, err
}
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
return nil, fmt.Errorf("expected a JSON object")
}
m := map[string]any{}
for dec.More() {
kt, err := dec.Token()
if err != nil {
return nil, err
}
key, ok := kt.(string)
if !ok {
return nil, fmt.Errorf("expected object key")
}
if skip[key] {
if err := skipJSONValue(dec); err != nil {
return nil, err
}
continue
}
var v any
if err := dec.Decode(&v); err != nil {
return nil, err
}
m[key] = v
}
if _, err := dec.Token(); err != nil {
return nil, err
}
return m, nil
}
func skipJSONValue(dec *json.Decoder) error {
tok, err := dec.Token()
if err != nil {
return err
}
delim, ok := tok.(json.Delim)
if !ok {
return nil
}
switch delim {
case '{':
for dec.More() {
if _, err := dec.Token(); err != nil {
return err
}
if err := skipJSONValue(dec); err != nil {
return err
}
}
_, err = dec.Token()
return err
case '[':
for dec.More() {
if err := skipJSONValue(dec); err != nil {
return err
}
}
_, err = dec.Token()
return err
default:
return fmt.Errorf("unexpected JSON delimiter %v", delim)
}
}
// emptyCollection reports an empty/null/[] body that some Ombi
// routes return instead of a structured empty payload.
func emptyCollection(raw []byte) bool {
trim := bytes.TrimSpace(raw)
if len(trim) == 0 || bytes.Equal(trim, []byte("null")) {
return true
}
return bytes.Equal(trim, []byte("[]"))
}
// transportErr maps client/transport failures onto ToolError codes.
// A timed-out or disconnected write has unknown outcome and is never
// marked retryable.
+77
View File
@@ -0,0 +1,77 @@
package tools
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
func TestEmptyCollection(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"", true},
{" \n", true},
{"null", true},
{"[]", true},
{" {}", false},
{`{"providerId":"1"}`, false},
{`[{"id":1}]`, false},
}
for _, c := range cases {
if got := emptyCollection([]byte(c.in)); got != c.want {
t.Errorf("emptyCollection(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestDecodeSkimArrayDropsSeasonRequests(t *testing.T) {
body := `[
{"id":1668,"title":"Reacher","seasonRequests":[{"seasonNumber":1,"episodes":[{"episodeNumber":1,"title":"Pilot"}]}]},
{"id":1396,"title":"Breaking Bad"}
]`
o := &op{warn: []string{}}
arr, fail := o.decodeSkimArray(strings.NewReader(body), maxBrowseBody, browseSkipKeys)
if fail != nil {
t.Fatalf("decode failed: %+v", fail.Error)
}
if len(arr) != 2 {
t.Fatalf("len = %d, want 2", len(arr))
}
if _, ok := arr[0]["seasonRequests"]; ok {
t.Errorf("seasonRequests was not skipped: %v", arr[0])
}
if arr[0]["title"] != "Reacher" {
t.Errorf("title = %v", arr[0]["title"])
}
if _, ok := arr[1]["seasonRequests"]; ok {
t.Errorf("second item unexpectedly has seasonRequests")
}
}
func TestDecodeSkimArrayBudget(t *testing.T) {
item := `{"id":1,"title":"x","seasonRequests":[` + strings.Repeat(`{"e":1},`, 100) + `{"e":2}]}`
body := "[" + item + "]"
o := &op{warn: []string{}}
_, fail := o.decodeSkimArray(strings.NewReader(body), 32, browseSkipKeys)
if fail == nil || fail.Error == nil || fail.Error.Code != "UPSTREAM_SCHEMA_MISMATCH" {
t.Fatalf("expected budget mismatch, got %+v", fail)
}
}
func TestSkipJSONValueNested(t *testing.T) {
raw := `{"keep":true,"seasonRequests":{"a":[1,{"b":[2,3]}],"c":"x"},"title":"T"}`
dec := json.NewDecoder(bytes.NewReader([]byte(raw)))
m, err := decodeSkimObject(dec, browseSkipKeys)
if err != nil {
t.Fatal(err)
}
if m["title"] != "T" || m["keep"] != true {
t.Errorf("kept fields = %v", m)
}
if _, ok := m["seasonRequests"]; ok {
t.Errorf("seasonRequests survived skip: %v", m)
}
}
+1 -5
View File
@@ -72,11 +72,7 @@ func (o *op) discoverBrowse(a *DiscoverArgs) *ToolResult {
}
pos, amt := bounds(a.Page)
path := fmt.Sprintf("/api/v2/Search/%s/%s/%d/%d", mediaSeg, segName, pos, amt)
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail
}
arr, fail := o.decodeArray(raw)
arr, fail := o.callSkimArray(path, maxBrowseBody, browseSkipKeys)
if fail != nil {
return fail
}
+4
View File
@@ -160,6 +160,10 @@ func (o *op) issuesProviderSummary(a *IssuesArgs) *ToolResult {
if fail != nil {
return fail
}
if emptyCollection(raw) {
return o.ok(&GroupPage{Kind: "group_page",
Items: []Group{}, Page: singlePage(0, "provider_groups")})
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
+108 -13
View File
@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"ombi-mcp/internal/ombi"
)
@@ -219,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 {
if fail != nil && !ratingsFallbackEligible(fail) {
return fail
}
m, fail := o.decodeObject(raw)
if fail != nil {
return fail
}
it := Media{Media: media, Identifiers: []Identifier{}, Title: a.Name, Year: a.Year}
for k, v := range m {
if i, ok := toInt(v); ok {
it.Ratings = append(it.Ratings, Reference{Name: k, Value: i})
} else if s, ok := v.(string); ok && s != "" {
it.Ratings = append(it.Ratings, Reference{Name: k, Value: s})
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)
}
}
}
return o.ok(&MediaPage{Kind: "media_page",
Items: []Media{it}, Page: singlePage(1, "media")})
// 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
}
return fallbackFail
}
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 {
refs = append(refs, Reference{Name: k, Value: i})
} else if s, ok := v.(string); ok && s != "" {
refs = append(refs, Reference{Name: k, Value: s})
}
}
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 {
+5 -2
View File
@@ -35,7 +35,10 @@ func handleRequests(ctx context.Context, env *Env, raw json.RawMessage) *ToolRes
}
// list maps media+status onto the v2 paged routes. `all` uses the
// base route, never an /all/ segment. Sort is always requestDate.
// base route, never an /all/ segment. Sort is always RequestedDate
// — Ombi's TypeDescriptor lookup is the C# property name, and the
// RAML example `requestDate` does not match (NullReferenceException
// on every non-empty page; #10).
func (o *op) requestsList(a *RequestsListArgs) *ToolResult {
var mediaSeg, kind string
switch a.Media {
@@ -73,7 +76,7 @@ func (o *op) requestsList(a *RequestsListArgs) *ToolResult {
if status != "all" {
path += "/" + status
}
path += fmt.Sprintf("/%d/%d/requestDate/%s", amt, pos, order)
path += fmt.Sprintf("/%d/%d/RequestedDate/%s", amt, pos, order)
raw, fail := o.call("GET", path, nil, nil)
if fail != nil {
return fail