@@ -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.
|
||||
@@ -118,7 +118,7 @@ TV identifier labels follow the origin route, not the upstream field name. The v
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
@@ -329,3 +329,9 @@ The gated live suite is committed and ready; run it with a sourced `.env`. It ad
|
||||
|
||||
- `#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.
|
||||
|
||||
@@ -2281,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)
|
||||
}
|
||||
|
||||
@@ -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,7 +79,9 @@ 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))
|
||||
@@ -773,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…",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user