M6 integration options: settings projections and media server discovery #23
@@ -177,7 +177,7 @@ Stats passes optional `from` and `to` query values. `update_check` is GET Job/up
|
||||
|
||||
`read_integration` prefers saved-settings GETs. Radarr 4K only applies to profiles/root folders, not tags. Sonarr language profiles uses `/v3/LanguageProfiles`. Lidarr Metadata is POST-only, so load saved Lidarr settings privately and construct its request internally. CouchPotato profile is singular and POST-only. Credential acquisition `/CouchPotato/apikey` is never a tool. RPC POST counterparts accepting settings are compatibility adapters to the same read intent; they do not add caller connection overrides.
|
||||
|
||||
Plex library lookup uses a known saved `machine_id`; Emby/Jellyfin info and Library POSTs use an authorized saved `server_id` to resolve the server settings privately. No arbitrary server object or connection destination is accepted. Routes that acquire Plex/Emby/Jellyfin access or provision accounts remain internal/manual because their authentication/side effects are not adequately specified here.
|
||||
Plex library lookup uses a known saved `machine_id` (discoverable via `read_integration plex servers`, which emits the machine identifier in `id`); Emby/Jellyfin info and Library POSTs use an authorized saved `server_id` to resolve the server settings privately (discoverable via `read_settings emby` / `jellyfin` / `plex`, which expose `/servers/<digits>/{id,serverId,machineIdentifier}` leaves in `values`). No arbitrary server object or connection destination is accepted. Routes that acquire Plex/Emby/Jellyfin access or provision accounts remain internal/manual because their authentication/side effects are not adequately specified here.
|
||||
|
||||
Settings section names and wire types are fully listed in [the registry](07-settings-types.md). Read-only flags and customization content are not accidentally accepted as writable sections. Before saving, privately read the original section, verify the revision, merge a typed patch preserving secrets and omitted fields, and POST the full correct wire model. An incomplete original object means save is unsupported. Redacted strings must never become stored credentials. Feature writes are enable/disable POSTs with `{name, enabled}`.
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Each per-tool output schema is intentionally a bounded projection, not the recur
|
||||
| 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. |
|
||||
| reference_page | Named ID/value records for genres, language/country lists, categories, claims, integration options, features and safe metadata. Names/IDs need verified upstream adapters. Unspecified response shapes fail explicitly instead of dumping raw data. |
|
||||
| reference_page | Named ID/value records for genres, language/country lists, categories, claims, integration options, features and safe metadata. Names/IDs need verified upstream adapters. `value` holds the native-typed identifier (e.g. integer or string) matching the resolved ID, falling back to name string when no ID matched; selection is deterministic across calls. Upstream corrupted labels (all-`?` mojibake or U+FFFD) are skipped for clean candidate keys; if only corrupted labels exist, the corrupted string is emitted and a `warnings[]` note is recorded. Unspecified response shapes fail explicitly instead of dumping raw data. |
|
||||
| metrics | Counts/quota/health/stats become named scalar values with an explicit instance/principal/selected-user scope. RequestQuota fields are has_limit, limit, remaining and next_request. Server request counts are pending, approved, available and denied. Do not equate false `hasLimit` with remaining=0. |
|
||||
| settings | Only allowlisted non-secret fields; nested settings are flattened using escaped JSON Pointer names, one scalar leaf per entry. Array indices are display paths only, never mutation identifiers. `revision` appears only when a safe corresponding patch can be offered. No credentials or destination URLs. |
|
||||
| calendar_page | Known date/title/episode/ID values, no invented time zone or promised date-range filtering. |
|
||||
|
||||
@@ -117,7 +117,7 @@ The body/response columns describe the **upstream** schema, not a promise to pas
|
||||
| 099 | [POST `/api/v1/Plex/Libraries`](../api/raml/api.raml#L1477) | A | `read_integration / plex_libraries` | body [PlexServers](../api/raml/types/Ombi.Core.Settings.Models.External.PlexServers.raml) | 200: [PlexLibrariesResponse](../api/raml/types/Ombi.Models.External.PlexLibrariesResponse.raml) | Private saved-server POST alternative. |
|
||||
| 100 | [GET `/api/v1/Plex/Libraries/{machineId}`](../api/raml/api.raml#L1497) | D | `read_integration / plex_libraries` | path `machineId`:string required | 200: [PlexLibrariesLiteResponse](../api/raml/types/Ombi.Models.External.PlexLibrariesLiteResponse.raml) | — |
|
||||
| 101 | [POST `/api/v1/Plex/user`](../api/raml/api.raml#L1510) | X | `Plex account provisioning` | body [PlexUserViewModel](../api/raml/types/Ombi.Models.External.PlexUserViewModel.raml) | 200: body unspecified | Side effects are insufficiently specified; no guessed read lookup. |
|
||||
| 102 | [GET `/api/v1/Plex/servers`](../api/raml/api.raml#L1525) | D | `read_integration / plex:servers` | none documented | 200: body unspecified | — |
|
||||
| 102 | [GET `/api/v1/Plex/servers`](../api/raml/api.raml#L1525) | D | `read_integration / plex:servers` | none documented | 200: object wrapper `{"success":bool,"servers":[...]}` | Verified upstream wrapper shape; projects servers array to reference_page with `id=machineId`, `name=serverName`, `value=serverId`. |
|
||||
| 103 | [POST `/api/v1/Plex/servers`](../api/raml/api.raml#L1525) | I | `Plex authentication/provisioning` | body [UserRequest](../api/raml/types/Ombi.Api.External.MediaServers.Plex.Models.UserRequest.raml) | 200: [PlexServersViewModel](../api/raml/types/Ombi.Models.External.PlexServersViewModel.raml) | Credential-bearing account/OAuth workflow stays outside model tools. |
|
||||
| 104 | [GET `/api/v1/Plex/friends`](../api/raml/api.raml#L1552) | D | `read_integration / plex:friends` | none documented | 200: array<[UsersViewModel](../api/raml/types/Ombi.Models.External.UsersViewModel.raml)> | — |
|
||||
| 105 | [POST `/api/v1/Plex/oauth`](../api/raml/api.raml#L1564) | I | `Plex authentication/provisioning` | body [PlexOAuthViewModel](../api/raml/types/Ombi.Models.PlexOAuthViewModel.raml) | 200: body unspecified | Credential-bearing account/OAuth workflow stays outside model tools. |
|
||||
|
||||
@@ -304,3 +304,16 @@ The gated live suite is committed and ready; run it with a sourced `.env`. It ad
|
||||
- `#7`: `GET /api/v1/Search/tv/info/{tvdbId}` (TVMaze-backed info route) upstream does not set request state flags accurately, returning `requested: false` regardless of truth. The adapter patches this by performing a bounded `GET /api/v1/Request/tv` parent scan and overlaying per-episode availability and request states.
|
||||
- `#10`: `GET /api/v2/Requests/{movie,tv,album}/...` (v2 lists) encounter a per-row `NullReferenceException` on non-empty results (observed on Ombi 4.53.10). The adapter surfaces this as `UPSTREAM_REJECTED` rather than silently replacing it with v1 `tvlite` payloads, preserving the expected v2 child-page contract.
|
||||
- `#10` search fallback: `GET /api/v1/Request/tv/search/{term}` on 4.53.10 suffers from a LINQ translation bug (upstream Ombi-app/Ombi#5420, fixed by #5421). The adapter gracefully falls back to a bounded v1 parent scan filtering locally on the term if the primary search route fails.
|
||||
|
||||
## M6 Findings — Integration options and server-id discovery (#16, #17, #18, #20)
|
||||
|
||||
- `#20`: `refOf` reference projections defaulted `value` to the first scalar map entry (`firstScalar`), which produced nondeterministic results depending on Go map iteration order (e.g. leaking logo paths, booleans, or unrelated weights). The contract is now deterministic: `value` holds the native-typed identifier matching the resolved ID key, falling back to name string if no ID matched, or omitted otherwise.
|
||||
- `#20` mojibake handling: language endpoints on Ombi 4.53.10 occasionally return corrupted strings like `"??????"` or containing `\uFFFD`. `refOf` skips corrupted candidates in favor of clean alternates (e.g. `name` instead of corrupted `english_name`); if all candidates are corrupted, the string is emitted and a degradation note is recorded in `warnings[]`.
|
||||
- `#17`: Root-folder records (`/api/v1/{Sonarr,Radarr,Lidarr}/RootFolders`) have no `name` property on the wire (only `path`). Category key table `refKeySet` now maps `path` to `name` and preserves `id` in `value` (e.g. `{id: "21", name: "/media/tv", value: 21}`), directly usable as `root_folder_id` in write operations.
|
||||
- `#16`: `GET /api/v1/Plex/servers` returns an object wrapper `{"success": true, "servers": [...]}` rather than a top-level array. `refsOrUsers` now inspects wrappers, verifies success/failure flags (mapping `success: false` to `UPSTREAM_REJECTED` with sanitized upstream messages), and decodes server entries with `id=machineId`, `name=serverName`, `value=serverId`.
|
||||
- `#16` sibling decode fixes: `refsOrScalar` was previously intercepting any valid JSON object in its `decodeScalar` branch and emitting a junk `{name: "<cat>"}` record, leaving nested-container extraction dead code. Reordering decode passes (`refArray` → `decodeObject` → `decodeScalar`) fixes `plex_libraries`, `media_server info`, and `media_server libraries`.
|
||||
- `#18`: Saved-server identity discovery: `flattenSettings` previously excluded `id`, `serverId`, and `machineIdentifier` everywhere, making it impossible to discover server IDs for `read_integration media_server` and `plex_libraries`. A scoped read-exemption allows server identity leaves under `/servers/<digits>/` to appear in `read_settings` values (top-level section IDs and credentials remain omitted; patches to server identity fields remain rejected with `INVALID_ARGUMENT`).
|
||||
|
||||
### Residual Verify items
|
||||
- Verify `PlexServersAddUserModel` and older Ombi instances where `servers` may be returned as a bare array (now tolerated alongside object wrappers).
|
||||
- Verify Emby `selectedLibraries[].key` ↔ MediaFolders `id` correspondence across diverse Emby/Jellyfin setups.
|
||||
|
||||
@@ -59,7 +59,7 @@ These types deliberately exclude credentials, connection destinations, internal
|
||||
|
||||
## Fields excluded from patches
|
||||
|
||||
The following exact field names are excluded recursively wherever encountered; `key` in a selected-library record is an identifier, not automatically a secret. Read projections also exclude private values and use an allowlist, not merely this name list.
|
||||
The following exact field names are excluded recursively wherever encountered; `key` in a selected-library record is an identifier, not automatically a secret. Read projections also exclude private values and use an allowlist, not merely this name list. In read projections (`read_settings`), a scoped exemption allows server identity fields (`id`, `serverId`, `machineIdentifier`) to be projected when they are direct leaves of a server record under `/servers/<digits>` (e.g. `/servers/0/id`, `/servers/0/serverId`, `/servers/0/machineIdentifier`), making saved servers discoverable for `read_integration media_server` and `plex_libraries`. In patches (`write_settings_patch`), these fields remain strictly excluded and unpatchable.
|
||||
|
||||
`accessToken`, `accountSid`, `administratorId`, `apiKey`, `applicationToken`, `applicationUrl`, `authToken`, `authorizationHeader`, `baseUrl`, `botApi`, `customDonationUrl`, `disableCertificateChecking`, `disableTLS`, `favicon`, `hasMigratedOldTvDbData`, `host`, `iconUrl`, `id`, `installId`, `ip`, `logo`, `machineIdentifier`, `password`, `plexAuthToken`, `port`, `processName`, `scriptLocation`, `serverHostname`, `serverId`, `set`, `ssl`, `subDir`, `useScript`, `userToken`, `webhookUrl`, `windowsService`, `windowsServiceName`, `wizard`.
|
||||
|
||||
|
||||
@@ -1488,17 +1488,17 @@ func TestM5TVDBDetailsRequestStateOverlay(t *testing.T) {
|
||||
|
||||
var m struct {
|
||||
Items []struct {
|
||||
Requested *bool `json:"requested"`
|
||||
Requested *bool `json:"requested"`
|
||||
RequestTargets []struct {
|
||||
Kind string `json:"kind"`
|
||||
ID int `json:"id"`
|
||||
ID int `json:"id"`
|
||||
} `json:"request_targets"`
|
||||
Seasons []struct {
|
||||
SeasonNumber int `json:"season_number"`
|
||||
Episodes []struct {
|
||||
EpisodeNumber int `json:"episode_number"`
|
||||
Requested *bool `json:"requested"`
|
||||
Available *bool `json:"available"`
|
||||
Episodes []struct {
|
||||
EpisodeNumber int `json:"episode_number"`
|
||||
Requested *bool `json:"requested"`
|
||||
Available *bool `json:"available"`
|
||||
} `json:"episodes"`
|
||||
} `json:"seasons"`
|
||||
} `json:"items"`
|
||||
@@ -1521,8 +1521,12 @@ func TestM5TVDBDetailsRequestStateOverlay(t *testing.T) {
|
||||
if s.SeasonNumber == 1 {
|
||||
for _, e := range s.Episodes {
|
||||
if e.EpisodeNumber == 1 {
|
||||
if e.Requested != nil { ep1Requested = *e.Requested }
|
||||
if e.Available != nil { ep1Available = *e.Available }
|
||||
if e.Requested != nil {
|
||||
ep1Requested = *e.Requested
|
||||
}
|
||||
if e.Available != nil {
|
||||
ep1Available = *e.Available
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1552,8 +1556,8 @@ func TestM5TVSearchFallback(t *testing.T) {
|
||||
|
||||
out := c.callTool(t, "read_requests", map[string]any{
|
||||
"action": "search",
|
||||
"media": "tv",
|
||||
"query": "trigger-500",
|
||||
"media": "tv",
|
||||
"query": "trigger-500",
|
||||
})
|
||||
data := requireOK(t, out)
|
||||
var page struct {
|
||||
@@ -1586,3 +1590,402 @@ func TestM5V2List500ErrorSurfaced(t *testing.T) {
|
||||
t.Errorf("expected error message to contain sanitized detail, got %q", out.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM6ReferenceDeterministicValueAndMojibake(t *testing.T) {
|
||||
mock := newMockOmbi(t, "jwt")
|
||||
c := spawnServer(t, mock.env())
|
||||
c.handshake(t)
|
||||
|
||||
// 1. Genres: value == 53 (float64 in unmarshaled json)
|
||||
outGenres := c.callTool(t, "read_reference", map[string]any{
|
||||
"action": "genres",
|
||||
"media": "movie",
|
||||
})
|
||||
dataGenres := requireOK(t, outGenres)
|
||||
var pageGenres struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(dataGenres, &pageGenres); err != nil {
|
||||
t.Fatalf("unmarshal genres: %v", err)
|
||||
}
|
||||
if len(pageGenres.Items) != 1 || pageGenres.Items[0].ID != "53" || pageGenres.Items[0].Name != "Thriller" || pageGenres.Items[0].Value != float64(53) {
|
||||
t.Errorf("unexpected genres item: %+v", pageGenres.Items)
|
||||
}
|
||||
|
||||
// 2. Profiles (options): value == 6 (not weight 7, not enabled false)
|
||||
outProfiles := c.callTool(t, "read_integration", map[string]any{
|
||||
"action": "options",
|
||||
"service": "radarr",
|
||||
"resource": "profiles",
|
||||
})
|
||||
dataProfiles := requireOK(t, outProfiles)
|
||||
var pageProfiles struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(dataProfiles, &pageProfiles); err != nil {
|
||||
t.Fatalf("unmarshal profiles: %v", err)
|
||||
}
|
||||
if len(pageProfiles.Items) != 1 || pageProfiles.Items[0].ID != "6" || pageProfiles.Items[0].Name != "HD" || pageProfiles.Items[0].Value != float64(6) {
|
||||
t.Errorf("unexpected profiles item: %+v", pageProfiles.Items)
|
||||
}
|
||||
|
||||
// 3. Watch providers: value == 8 (not provider_name, not logo_path)
|
||||
outWP := c.callTool(t, "read_reference", map[string]any{
|
||||
"action": "watch_providers",
|
||||
"media": "movie",
|
||||
})
|
||||
dataWP := requireOK(t, outWP)
|
||||
var pageWP struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(dataWP, &pageWP); err != nil {
|
||||
t.Fatalf("unmarshal watch providers: %v", err)
|
||||
}
|
||||
if len(pageWP.Items) != 1 || pageWP.Items[0].ID != "8" || pageWP.Items[0].Name != "Netflix" || pageWP.Items[0].Value != float64(8) {
|
||||
t.Errorf("unexpected watch provider item: %+v", pageWP.Items)
|
||||
}
|
||||
|
||||
// 4. Languages: mojibake skip (ky -> Кыргызча) + all-corrupted warning (zz -> ?????? + warning)
|
||||
outLang := c.callTool(t, "read_reference", map[string]any{
|
||||
"action": "languages",
|
||||
})
|
||||
dataLang := requireOK(t, outLang)
|
||||
var pageLang struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(dataLang, &pageLang); err != nil {
|
||||
t.Fatalf("unmarshal languages: %v", err)
|
||||
}
|
||||
if len(pageLang.Items) != 2 {
|
||||
t.Fatalf("expected 2 language items, got %d", len(pageLang.Items))
|
||||
}
|
||||
if pageLang.Items[0].ID != "ky" || pageLang.Items[0].Name != "Кыргызча" {
|
||||
t.Errorf("expected ky to have name 'Кыргызча', got %+v", pageLang.Items[0])
|
||||
}
|
||||
if pageLang.Items[1].ID != "zz" || pageLang.Items[1].Name != "??????" {
|
||||
t.Errorf("expected zz to have name '??????', got %+v", pageLang.Items[1])
|
||||
}
|
||||
hasWarn := false
|
||||
for _, w := range outLang.Envelope.Warnings {
|
||||
if strings.Contains(w, "upstream language label appears corrupted") && strings.Contains(w, "zz") {
|
||||
hasWarn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasWarn {
|
||||
t.Errorf("expected corrupted label warning for zz in warnings: %v", outLang.Envelope.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM6IntegrationRootFolders(t *testing.T) {
|
||||
mock := newMockOmbi(t, "jwt")
|
||||
c := spawnServer(t, mock.env())
|
||||
c.handshake(t)
|
||||
|
||||
out := c.callTool(t, "read_integration", map[string]any{
|
||||
"action": "options",
|
||||
"service": "sonarr",
|
||||
"resource": "root_folders",
|
||||
})
|
||||
data := requireOK(t, out)
|
||||
var page struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
Category string `json:"category"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &page); err != nil {
|
||||
t.Fatalf("unmarshal root_folders: %v", err)
|
||||
}
|
||||
if len(page.Items) != 1 {
|
||||
t.Fatalf("expected 1 root folder, got %d", len(page.Items))
|
||||
}
|
||||
rf := page.Items[0]
|
||||
if rf.ID != "21" || rf.Name != "/media/tv" || rf.Value != float64(21) || rf.Category != "root_folder" {
|
||||
t.Errorf("unexpected root_folder projection: %+v", rf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM6PlexServersAndLibraries(t *testing.T) {
|
||||
mock := newMockOmbi(t, "jwt")
|
||||
c := spawnServer(t, mock.env())
|
||||
c.handshake(t)
|
||||
|
||||
// 1. Plex servers decoded from object wrapper {"success":true,"servers":[...]}
|
||||
outServers := c.callTool(t, "read_integration", map[string]any{
|
||||
"action": "plex",
|
||||
"resource": "servers",
|
||||
})
|
||||
dataServers := requireOK(t, outServers)
|
||||
var pageServers struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
Category string `json:"category"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(dataServers, &pageServers); err != nil {
|
||||
t.Fatalf("unmarshal servers: %v", err)
|
||||
}
|
||||
if len(pageServers.Items) != 1 {
|
||||
t.Fatalf("expected 1 plex server, got %d", len(pageServers.Items))
|
||||
}
|
||||
srv := pageServers.Items[0]
|
||||
if srv.ID != "mach-plex-1" || srv.Name != "Main Plex" || srv.Value != float64(7) || srv.Category != "plex_server" {
|
||||
t.Errorf("unexpected plex server projection: %+v", srv)
|
||||
}
|
||||
|
||||
// 2. Plex libraries round-trip using machine_id discovered from servers
|
||||
outLibs := c.callTool(t, "read_integration", map[string]any{
|
||||
"action": "plex_libraries",
|
||||
"machine_id": srv.ID,
|
||||
})
|
||||
dataLibs := requireOK(t, outLibs)
|
||||
var pageLibs struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
Category string `json:"category"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(dataLibs, &pageLibs); err != nil {
|
||||
t.Fatalf("unmarshal libraries: %v", err)
|
||||
}
|
||||
if len(pageLibs.Items) != 2 {
|
||||
t.Fatalf("expected 2 libraries, got %d", len(pageLibs.Items))
|
||||
}
|
||||
if pageLibs.Items[0].ID != "3" || pageLibs.Items[0].Name != "TV Shows" || pageLibs.Items[0].Value != "3" {
|
||||
t.Errorf("unexpected library item 0: %+v", pageLibs.Items[0])
|
||||
}
|
||||
if pageLibs.Items[1].ID != "1" || pageLibs.Items[1].Name != "Movies" || pageLibs.Items[1].Value != "1" {
|
||||
t.Errorf("unexpected library item 1: %+v", pageLibs.Items[1])
|
||||
}
|
||||
|
||||
// 3. Plex libraries with broken machineId surfaces UPSTREAM_REJECTED + message
|
||||
outBroken := c.callTool(t, "read_integration", map[string]any{
|
||||
"action": "plex_libraries",
|
||||
"machine_id": "broken",
|
||||
})
|
||||
errBroken := requireErr(t, outBroken, "UPSTREAM_REJECTED")
|
||||
if !strings.Contains(errBroken.Message, "could not reach server") {
|
||||
t.Errorf("expected rejection message to contain 'could not reach server', got %q", errBroken.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM6PlexFriendsAndWatchlistUsers(t *testing.T) {
|
||||
mock := newMockOmbi(t, "jwt")
|
||||
c := spawnServer(t, mock.env())
|
||||
c.handshake(t)
|
||||
|
||||
// Friends -> user_page
|
||||
outFriends := c.callTool(t, "read_integration", map[string]any{
|
||||
"action": "plex",
|
||||
"resource": "friends",
|
||||
})
|
||||
dataFriends := requireOK(t, outFriends)
|
||||
var pageFriends struct {
|
||||
Kind string `json:"kind"`
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
UserName string `json:"user_name"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(dataFriends, &pageFriends); err != nil {
|
||||
t.Fatalf("unmarshal friends: %v", err)
|
||||
}
|
||||
if pageFriends.Kind != "user_page" || len(pageFriends.Items) != 1 || pageFriends.Items[0].UserName != "plexfriend" {
|
||||
t.Errorf("unexpected friends: %+v", pageFriends)
|
||||
}
|
||||
|
||||
// Watchlist users -> user_page
|
||||
outWL := c.callTool(t, "read_integration", map[string]any{
|
||||
"action": "plex",
|
||||
"resource": "watchlist_users",
|
||||
})
|
||||
dataWL := requireOK(t, outWL)
|
||||
var pageWL struct {
|
||||
Kind string `json:"kind"`
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
UserName string `json:"user_name"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(dataWL, &pageWL); err != nil {
|
||||
t.Fatalf("unmarshal watchlist_users: %v", err)
|
||||
}
|
||||
if pageWL.Kind != "user_page" || len(pageWL.Items) != 1 || pageWL.Items[0].UserName != "wl" {
|
||||
t.Errorf("unexpected watchlist_users: %+v", pageWL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestM6ServerIdentityDiscoveryAndMediaServer(t *testing.T) {
|
||||
mock := newMockOmbi(t, "jwt")
|
||||
c := spawnServer(t, mock.env())
|
||||
c.handshake(t)
|
||||
|
||||
// 1. read_settings emby: server identity leaves exposed, secrets/top-level id omitted
|
||||
outSettings := c.callTool(t, "read_settings", map[string]any{
|
||||
"section": "emby",
|
||||
})
|
||||
dataSettings := requireOK(t, outSettings)
|
||||
var s struct {
|
||||
Kind string `json:"kind"`
|
||||
Section string `json:"section"`
|
||||
Revision string `json:"revision"`
|
||||
Values []struct {
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
} `json:"values"`
|
||||
OmittedFields []string `json:"omitted_fields"`
|
||||
}
|
||||
if err := json.Unmarshal(dataSettings, &s); err != nil {
|
||||
t.Fatalf("settings decode: %v", err)
|
||||
}
|
||||
|
||||
valMap := map[string]any{}
|
||||
for _, v := range s.Values {
|
||||
valMap[v.Name] = v.Value
|
||||
}
|
||||
omittedSet := map[string]bool{}
|
||||
for _, o := range s.OmittedFields {
|
||||
omittedSet[o] = true
|
||||
}
|
||||
|
||||
if valMap["/servers/0/id"] != float64(3) {
|
||||
t.Errorf("expected /servers/0/id == 3, got %v", valMap["/servers/0/id"])
|
||||
}
|
||||
if valMap["/servers/0/serverId"] != "emby-guid" {
|
||||
t.Errorf("expected /servers/0/serverId == 'emby-guid', got %v", valMap["/servers/0/serverId"])
|
||||
}
|
||||
if valMap["/servers/0/name"] != "Emby One" {
|
||||
t.Errorf("expected /servers/0/name == 'Emby One', got %v", valMap["/servers/0/name"])
|
||||
}
|
||||
if _, ok := valMap["/id"]; ok {
|
||||
t.Errorf("top-level /id must NOT be present in values")
|
||||
}
|
||||
if _, ok := valMap["/servers/0/apiKey"]; ok {
|
||||
t.Errorf("apiKey must NOT be present in values")
|
||||
}
|
||||
if _, ok := valMap["/servers/0/ip"]; ok {
|
||||
t.Errorf("ip must NOT be present in values")
|
||||
}
|
||||
|
||||
if !omittedSet["/id"] {
|
||||
t.Errorf("expected /id in omitted_fields")
|
||||
}
|
||||
if !omittedSet["/servers/0/apiKey"] {
|
||||
t.Errorf("expected /servers/0/apiKey in omitted_fields")
|
||||
}
|
||||
if !omittedSet["/servers/0/ip"] {
|
||||
t.Errorf("expected /servers/0/ip in omitted_fields")
|
||||
}
|
||||
if omittedSet["/servers/0/id"] {
|
||||
t.Errorf("/servers/0/id must NOT be in omitted_fields")
|
||||
}
|
||||
if omittedSet["/servers/0/serverId"] {
|
||||
t.Errorf("/servers/0/serverId must NOT be in omitted_fields")
|
||||
}
|
||||
|
||||
// 2. Patch rejection: attempting to patch /servers/0/id must return INVALID_ARGUMENT
|
||||
outPatch := c.callTool(t, "write_settings_patch", map[string]any{
|
||||
"section": "emby",
|
||||
"revision": s.Revision,
|
||||
"changes": map[string]any{
|
||||
"servers": []any{
|
||||
map[string]any{"id": 999},
|
||||
},
|
||||
},
|
||||
})
|
||||
requireErr(t, outPatch, "INVALID_ARGUMENT")
|
||||
|
||||
// 3. media_server emby info round-trip using discovered serverId "emby-guid"
|
||||
outInfo := c.callTool(t, "read_integration", map[string]any{
|
||||
"action": "media_server",
|
||||
"service": "emby",
|
||||
"resource": "info",
|
||||
"server_id": "emby-guid",
|
||||
})
|
||||
dataInfo := requireOK(t, outInfo)
|
||||
var pageInfo struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(dataInfo, &pageInfo); err != nil {
|
||||
t.Fatalf("unmarshal emby info: %v", err)
|
||||
}
|
||||
if len(pageInfo.Items) != 1 || pageInfo.Items[0].ID != "emby-guid" || pageInfo.Items[0].Name != "Emby One" {
|
||||
t.Errorf("unexpected emby info item: %+v", pageInfo.Items)
|
||||
}
|
||||
assertNoLeak(t, outInfo.Raw)
|
||||
|
||||
// 4. media_server emby libraries round-trip using discovered numeric id "3"
|
||||
outLibs := c.callTool(t, "read_integration", map[string]any{
|
||||
"action": "media_server",
|
||||
"service": "emby",
|
||||
"resource": "libraries",
|
||||
"server_id": "3",
|
||||
})
|
||||
dataLibs := requireOK(t, outLibs)
|
||||
var pageLibs struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(dataLibs, &pageLibs); err != nil {
|
||||
t.Fatalf("unmarshal emby libraries: %v", err)
|
||||
}
|
||||
if len(pageLibs.Items) != 1 || pageLibs.Items[0].ID != "f137" || pageLibs.Items[0].Name != "Movies" {
|
||||
t.Errorf("unexpected emby library item: %+v", pageLibs.Items)
|
||||
}
|
||||
assertNoLeak(t, outLibs.Raw)
|
||||
|
||||
// Assert POST bodies to Emby info and Library carried the saved server object
|
||||
reqs := mock.requests()
|
||||
infoFound := false
|
||||
libFound := false
|
||||
for _, r := range reqs {
|
||||
if r.Method == "POST" && r.Path == "/api/v1/Emby/info" {
|
||||
infoFound = true
|
||||
if !strings.Contains(string(r.Body), `"apiKey":"SECRET"`) || !strings.Contains(string(r.Body), `"emby-guid"`) {
|
||||
t.Errorf("POST /api/v1/Emby/info did not carry saved server object: %s", r.Body)
|
||||
}
|
||||
}
|
||||
if r.Method == "POST" && r.Path == "/api/v1/Emby/Library" {
|
||||
libFound = true
|
||||
if !strings.Contains(string(r.Body), `"apiKey":"SECRET"`) || !strings.Contains(string(r.Body), `"emby-guid"`) {
|
||||
t.Errorf("POST /api/v1/Emby/Library did not carry saved server object: %s", r.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !infoFound {
|
||||
t.Errorf("POST /api/v1/Emby/info was not called")
|
||||
}
|
||||
if !libFound {
|
||||
t.Errorf("POST /api/v1/Emby/Library was not called")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package integration_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -509,3 +510,121 @@ func TestLiveSearchMultiArtistMapped(t *testing.T) {
|
||||
}
|
||||
assertNoLeak(t, out.Raw)
|
||||
}
|
||||
|
||||
func TestLiveReferenceGenresValue(t *testing.T) {
|
||||
c := liveServer(t)
|
||||
out := c.callTool(t, "read_reference", map[string]any{
|
||||
"action": "genres",
|
||||
"media": "movie",
|
||||
})
|
||||
data := requireOK(t, out)
|
||||
var page struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &page); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, data)
|
||||
}
|
||||
if len(page.Items) == 0 {
|
||||
t.Skip("live genres returned no items")
|
||||
}
|
||||
for _, it := range page.Items {
|
||||
idNum, ok := it.Value.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("genre item %q id %q has non-numeric value: %v (%T)", it.Name, it.ID, it.Value, it.Value)
|
||||
}
|
||||
if fmt.Sprintf("%.0f", idNum) != it.ID {
|
||||
t.Fatalf("genre item %q id %q does not match value %.0f", it.Name, it.ID, idNum)
|
||||
}
|
||||
}
|
||||
assertNoLeak(t, out.Raw)
|
||||
}
|
||||
|
||||
func TestLivePlexServersAndLibraries(t *testing.T) {
|
||||
c := liveServer(t)
|
||||
out := c.callTool(t, "read_integration", map[string]any{
|
||||
"action": "plex",
|
||||
"resource": "servers",
|
||||
})
|
||||
if out.IsError {
|
||||
t.Skip("live plex servers returned error")
|
||||
}
|
||||
data := requireOK(t, out)
|
||||
var page struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &page); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, data)
|
||||
}
|
||||
if len(page.Items) == 0 {
|
||||
t.Skip("no plex servers configured")
|
||||
}
|
||||
server := page.Items[0]
|
||||
if server.ID == "" {
|
||||
t.Fatalf("plex server ID (machineId) is empty: %+v", server)
|
||||
}
|
||||
outLibs := c.callTool(t, "read_integration", map[string]any{
|
||||
"action": "plex_libraries",
|
||||
"machine_id": server.ID,
|
||||
})
|
||||
requireOK(t, outLibs)
|
||||
assertNoLeak(t, outLibs.Raw)
|
||||
}
|
||||
|
||||
func TestLiveServerIdentityDiscoveryAndMediaServer(t *testing.T) {
|
||||
c := liveServer(t)
|
||||
var foundServerID string
|
||||
var foundService string
|
||||
for _, svc := range []string{"emby", "jellyfin", "plex"} {
|
||||
out := c.callTool(t, "read_settings", map[string]any{
|
||||
"section": svc,
|
||||
})
|
||||
if out.IsError {
|
||||
continue
|
||||
}
|
||||
data := requireOK(t, out)
|
||||
var s struct {
|
||||
Values []struct {
|
||||
Name string `json:"name"`
|
||||
Value any `json:"value"`
|
||||
} `json:"values"`
|
||||
OmittedFields []string `json:"omitted_fields"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, data)
|
||||
}
|
||||
for _, v := range s.Values {
|
||||
if strings.HasPrefix(v.Name, "/servers/") {
|
||||
if strings.HasSuffix(v.Name, "/id") || strings.HasSuffix(v.Name, "/serverId") || strings.HasSuffix(v.Name, "/machineIdentifier") {
|
||||
if foundServerID == "" && (svc == "emby" || svc == "jellyfin") {
|
||||
foundServerID = fmt.Sprintf("%v", v.Value)
|
||||
foundService = svc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, o := range s.OmittedFields {
|
||||
if strings.HasPrefix(o, "/servers/") && (strings.HasSuffix(o, "/id") || strings.HasSuffix(o, "/serverId") || strings.HasSuffix(o, "/machineIdentifier")) {
|
||||
t.Errorf("server identity field %q found in omitted_fields for %s", o, svc)
|
||||
}
|
||||
}
|
||||
}
|
||||
if foundServerID != "" && foundService != "" {
|
||||
outInfo := c.callTool(t, "read_integration", map[string]any{
|
||||
"action": "media_server",
|
||||
"service": foundService,
|
||||
"resource": "info",
|
||||
"server_id": foundServerID,
|
||||
})
|
||||
if !outInfo.IsError {
|
||||
assertNoLeak(t, outInfo.Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
|
||||
m.settings = map[string]map[string]any{
|
||||
"/api/v1/Settings/customization": mockCustomization(),
|
||||
"/api/v1/Settings/notifications/discord": mockDiscordSettings(),
|
||||
"/api/v1/Settings/emby": mockMediaServerSettings("Emby One", "emby-guid", 3),
|
||||
"/api/v1/Settings/jellyfin": mockMediaServerSettings("Jellyfin One", "jf-guid", 4),
|
||||
"/api/v1/Settings/plex": mockMediaServerSettings("Plex One", "plex-guid", 5),
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/v1/Token", m.handleToken)
|
||||
@@ -90,6 +93,15 @@ func newMockOmbi(t *testing.T, mode string) *mockOmbi {
|
||||
mux.HandleFunc("POST /api/v1/Settings/customization", m.wrap(m.settingsPost))
|
||||
mux.HandleFunc("GET /api/v1/Settings/notifications/discord", m.wrap(m.settingsGet))
|
||||
mux.HandleFunc("POST /api/v1/Settings/notifications/discord", m.wrap(m.settingsPost))
|
||||
mux.HandleFunc("GET /api/v1/Settings/emby", m.wrap(m.settingsGet))
|
||||
mux.HandleFunc("POST /api/v1/Settings/emby", m.wrap(m.settingsPost))
|
||||
mux.HandleFunc("GET /api/v1/Settings/jellyfin", m.wrap(m.settingsGet))
|
||||
mux.HandleFunc("POST /api/v1/Settings/jellyfin", m.wrap(m.settingsPost))
|
||||
mux.HandleFunc("GET /api/v1/Settings/plex", m.wrap(m.settingsGet))
|
||||
mux.HandleFunc("POST /api/v1/Settings/plex", m.wrap(m.settingsPost))
|
||||
mux.HandleFunc("GET /api/v1/Emby/users", m.wrap(m.fixed(`[{"id":"e-1","username":"emb"}]`)))
|
||||
mux.HandleFunc("POST /api/v1/Emby/info", m.wrap(m.fixed(`{"id":"emby-guid","serverName":"Emby One","version":"4.8.0"}`)))
|
||||
mux.HandleFunc("POST /api/v1/Emby/Library", m.wrap(m.fixed(`{"items":[{"name":"Movies","serverId":"emby-guid","id":"f137","collectionType":"movies"}]}`)))
|
||||
mux.HandleFunc("GET /api/v1/Request/movie/search/{q}", m.wrap(m.malformed))
|
||||
mux.HandleFunc("GET /api/v1/Request/count", m.wrap(m.fixed(`{"pending":3,"approved":2,"available":5,"denied":1}`)))
|
||||
mux.HandleFunc("GET /api/v1/Request/movie/total", m.wrap(m.fixed(`7`)))
|
||||
@@ -98,6 +110,15 @@ 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/Radarr/Profiles", m.wrap(m.fixed(`[{"id":6,"name":"HD","weight":7,"enabled":false}]`)))
|
||||
mux.HandleFunc("GET /api/v2/Search/Genres/{media}", m.wrap(m.fixed(`[{"id":53,"name":"Thriller"}]`)))
|
||||
mux.HandleFunc("GET /api/v2/Search/Languages", m.wrap(m.fixed(`[{"iso_639_1":"ky","english_name":"??????","name":"Кыргызча"},{"iso_639_1":"zz","english_name":"??????","name":"??????"}]`)))
|
||||
mux.HandleFunc("GET /api/v1/TheMovieDb/WatchProviders/{media}", m.wrap(m.fixed(`[{"provider_id":8,"provider_name":"Netflix","logo_path":"/x.png"}]`)))
|
||||
mux.HandleFunc("GET /api/v1/Sonarr/RootFolders", m.wrap(m.fixed(`[{"id":21,"path":"/media/tv","freespace":9e9}]`)))
|
||||
mux.HandleFunc("GET /api/v1/Plex/servers", m.wrap(m.fixed(`{"success":true,"servers":[{"serverId":7,"machineId":"mach-plex-1","serverName":"Main Plex"}]}`)))
|
||||
mux.HandleFunc("GET /api/v1/Plex/Libraries/{machineId}", m.wrap(m.plexLibraries))
|
||||
mux.HandleFunc("GET /api/v1/Plex/friends", m.wrap(m.fixed(`[{"id":"pf-1","username":"plexfriend"}]`)))
|
||||
mux.HandleFunc("GET /api/v1/Plex/WatchlistUsers", m.wrap(m.fixed(`[{"userId":"w-1","userName":"wl","syncStatus":1}]`)))
|
||||
mux.HandleFunc("/api/v1/", m.wrap(m.catchAll)) // fallthrough: 404
|
||||
m.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(m.Server.Close)
|
||||
@@ -521,6 +542,24 @@ func mockDiscordSettings() map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func mockMediaServerSettings(name, serverGuid string, serverID int) map[string]any {
|
||||
return map[string]any{
|
||||
"enable": true,
|
||||
"id": 9,
|
||||
"servers": []any{
|
||||
map[string]any{
|
||||
"id": serverID,
|
||||
"serverId": serverGuid,
|
||||
"name": name,
|
||||
"apiKey": "SECRET",
|
||||
"administratorId": "a",
|
||||
"ip": "10.0.0.5",
|
||||
"port": 8096,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
func (m *mockOmbi) movieList(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -541,9 +580,9 @@ func (m *mockOmbi) tvParentList(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
m.json([]any{
|
||||
map[string]any{
|
||||
"id": 42,
|
||||
"id": 42,
|
||||
"tvDbId": 81189,
|
||||
"title": "trigger-500 and something", // For search fallback test
|
||||
"title": "trigger-500 and something", // For search fallback test
|
||||
"childRequests": []any{
|
||||
map[string]any{
|
||||
"seasonRequests": []any{
|
||||
@@ -814,6 +853,16 @@ func (m *mockOmbi) userGet(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockOmbi) plexLibraries(w http.ResponseWriter, r *http.Request) {
|
||||
if r.PathValue("machineId") == "broken" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"successful":false,"message":"could not reach server"}`))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"successful":true,"data":[{"id":"3","key":"3","type":"show","title":"TV Shows"},{"id":"1","key":"1","type":"movie","title":"Movies"}]}`))
|
||||
}
|
||||
|
||||
// mockEnv returns the server env pointing at this mock in the
|
||||
// requested mode, plus all three bundles.
|
||||
func (m *mockOmbi) env() map[string]string {
|
||||
|
||||
+143
-33
@@ -125,47 +125,130 @@ func (o *op) integrationOptions(a *IntegrationArgs) *ToolResult {
|
||||
return o.refsOrScalar(raw, cat)
|
||||
}
|
||||
|
||||
type refKeySet struct {
|
||||
id []string
|
||||
name []string
|
||||
value []string
|
||||
}
|
||||
|
||||
var defaultOptionRefKeys = refKeySet{
|
||||
id: []string{"id", "value", "key"},
|
||||
name: []string{"name", "label", "value", "path"},
|
||||
value: nil,
|
||||
}
|
||||
|
||||
var optionRefKeys = map[string]refKeySet{
|
||||
"root_folder": {
|
||||
id: []string{"id"},
|
||||
name: []string{"path", "name", "label", "value"},
|
||||
value: nil,
|
||||
},
|
||||
"plex_library": {
|
||||
id: []string{"key", "id"},
|
||||
name: []string{"title", "name", "label"},
|
||||
value: nil,
|
||||
},
|
||||
"plex_server": {
|
||||
id: []string{"machineId", "serverId", "id", "machineIdentifier", "key"},
|
||||
name: []string{"serverName", "name", "title"},
|
||||
value: []string{"serverId", "machineId"},
|
||||
},
|
||||
"emby_info": {
|
||||
id: []string{"id"},
|
||||
name: []string{"serverName", "name"},
|
||||
value: nil,
|
||||
},
|
||||
"jellyfin_info": {
|
||||
id: []string{"id"},
|
||||
name: []string{"serverName", "name"},
|
||||
value: nil,
|
||||
},
|
||||
"emby_libraries": {
|
||||
id: []string{"id", "key"},
|
||||
name: []string{"name", "title"},
|
||||
value: nil,
|
||||
},
|
||||
"jellyfin_libraries": {
|
||||
id: []string{"id", "key"},
|
||||
name: []string{"name", "title"},
|
||||
value: nil,
|
||||
},
|
||||
}
|
||||
|
||||
func refKeysFor(cat string, defaults refKeySet) refKeySet {
|
||||
if ks, ok := optionRefKeys[cat]; ok {
|
||||
return ks
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
func isRejectedObject(m map[string]any) (bool, string) {
|
||||
for _, k := range []string{"success", "successful"} {
|
||||
if v, ok := m[k]; ok {
|
||||
if b, ok := v.(bool); ok && !b {
|
||||
msg := jstr(m, "message", "Message", "errorMessage", "ErrorMessage")
|
||||
if msg == "" {
|
||||
msg = "upstream operation was unsuccessful"
|
||||
}
|
||||
return true, sanitizeText(msg, maxSanitizedMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// refsOrScalar projects an option response: arrays become reference
|
||||
// items; scalars/objects become single records.
|
||||
// items; objects become nested arrays or single records; scalars become
|
||||
// single records.
|
||||
func (o *op) refsOrScalar(raw []byte, cat string) *ToolResult {
|
||||
if items, fail := o.refArray(raw,
|
||||
[]string{"id", "value", "key"}, []string{"name", "label", "value"}, cat); fail == nil {
|
||||
topKeys := refKeysFor(cat, defaultOptionRefKeys)
|
||||
if items, fail := o.refArray(raw, topKeys.id, topKeys.name, cat); fail == nil {
|
||||
return o.ok(&ReferencePage{Kind: "reference_page",
|
||||
Items: items, Page: singlePage(len(items), "references")})
|
||||
}
|
||||
// Non-array bodies: scalar or single object → one reference.
|
||||
if v, fail := o.decodeScalar(raw); fail == nil && v != nil {
|
||||
r := Reference{Name: cat, Category: cat}
|
||||
switch t := v.(type) {
|
||||
case string, float64, bool:
|
||||
r.Value = t
|
||||
if m, fail := o.decodeObject(raw); fail == nil {
|
||||
if rejected, msg := isRejectedObject(m); rejected {
|
||||
return o.fail("UPSTREAM_REJECTED", msg, false)
|
||||
}
|
||||
// Nested containers (e.g. CouchPotatoProfiles.list, Plex Libraries data, Media Server items)
|
||||
// project their first array member list.
|
||||
nestedDefaults := refKeySet{
|
||||
id: []string{"_id", "id", "key", "value"},
|
||||
name: []string{"label", "name", "title", "serverName", "path"},
|
||||
}
|
||||
nestedKeys := refKeysFor(cat, nestedDefaults)
|
||||
for _, v := range m {
|
||||
if arr, ok := v.([]any); ok {
|
||||
items := []Reference{}
|
||||
for _, e := range arr {
|
||||
if em, ok := e.(map[string]any); ok {
|
||||
items = append(items, o.refOf(em,
|
||||
nestedKeys.id, nestedKeys.name, nestedKeys.value, cat))
|
||||
}
|
||||
}
|
||||
items = capItems(o, items)
|
||||
return o.ok(&ReferencePage{Kind: "reference_page",
|
||||
Items: items, Page: singlePage(len(items), "references")})
|
||||
}
|
||||
}
|
||||
fallbackDefaults := refKeySet{
|
||||
id: []string{"id"},
|
||||
name: []string{"name", "serverName", "title", "label", "path"},
|
||||
}
|
||||
fallbackKeys := refKeysFor(cat, fallbackDefaults)
|
||||
r := o.refOf(m, fallbackKeys.id, fallbackKeys.name, fallbackKeys.value, cat)
|
||||
return o.ok(&ReferencePage{Kind: "reference_page",
|
||||
Items: []Reference{r}, Page: singlePage(1, "references")})
|
||||
}
|
||||
m, fail := o.decodeObject(raw)
|
||||
if fail != nil {
|
||||
return fail
|
||||
}
|
||||
// Nested containers (e.g. CouchPotatoProfiles.list) project
|
||||
// their first array member list.
|
||||
for _, v := range m {
|
||||
if arr, ok := v.([]any); ok {
|
||||
items := []Reference{}
|
||||
for _, e := range arr {
|
||||
if em, ok := e.(map[string]any); ok {
|
||||
items = append(items, refOf(em,
|
||||
[]string{"_id", "id", "value"}, []string{"label", "name"}, nil, cat))
|
||||
}
|
||||
}
|
||||
items = capItems(o, items)
|
||||
if v, fail := o.decodeScalar(raw); fail == nil && v != nil {
|
||||
switch t := v.(type) {
|
||||
case string, float64, bool:
|
||||
r := Reference{Name: cat, Category: cat, Value: t}
|
||||
return o.ok(&ReferencePage{Kind: "reference_page",
|
||||
Items: items, Page: singlePage(len(items), "references")})
|
||||
Items: []Reference{r}, Page: singlePage(1, "references")})
|
||||
}
|
||||
}
|
||||
r := refOf(m, []string{"id"}, []string{"name"}, nil, cat)
|
||||
return o.ok(&ReferencePage{Kind: "reference_page",
|
||||
Items: []Reference{r}, Page: singlePage(1, "references")})
|
||||
return o.fail("UPSTREAM_SCHEMA_MISMATCH", "upstream response was not a JSON array, object, or scalar", false)
|
||||
}
|
||||
|
||||
func (o *op) integrationPlex(a *IntegrationArgs) *ToolResult {
|
||||
@@ -188,12 +271,39 @@ func (o *op) integrationPlex(a *IntegrationArgs) *ToolResult {
|
||||
}
|
||||
|
||||
// refsOrUsers projects a response that may be an array of user-like
|
||||
// objects or reference-like objects.
|
||||
// objects or reference-like objects, or an object wrapper containing them.
|
||||
func (o *op) refsOrUsers(raw []byte, cat string) *ToolResult {
|
||||
arr, fail := o.decodeArray(raw)
|
||||
if fail != nil {
|
||||
return fail
|
||||
m, objFail := o.decodeObject(raw)
|
||||
if objFail != nil {
|
||||
return fail
|
||||
}
|
||||
if rejected, msg := isRejectedObject(m); rejected {
|
||||
return o.fail("UPSTREAM_REJECTED", msg, false)
|
||||
}
|
||||
var innerArr []map[string]any
|
||||
for _, v := range m {
|
||||
if a, ok := v.([]any); ok {
|
||||
innerArr = make([]map[string]any, 0, len(a))
|
||||
for _, elem := range a {
|
||||
if em, ok := elem.(map[string]any); ok {
|
||||
innerArr = append(innerArr, em)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if innerArr == nil {
|
||||
return fail
|
||||
}
|
||||
arr = innerArr
|
||||
}
|
||||
refKeys := refKeysFor(cat, refKeySet{
|
||||
id: []string{"id", "machineIdentifier", "key"},
|
||||
name: []string{"name", "title"},
|
||||
value: nil,
|
||||
})
|
||||
// User-shaped records (id+userName/username/email) → user_page.
|
||||
users := []User{}
|
||||
refs := []Reference{}
|
||||
@@ -201,8 +311,8 @@ func (o *op) refsOrUsers(raw []byte, cat string) *ToolResult {
|
||||
if jstr(m, "userName", "username", "email") != "" {
|
||||
users = append(users, projectUser(m))
|
||||
} else {
|
||||
refs = append(refs, refOf(m,
|
||||
[]string{"id", "machineIdentifier", "key"}, []string{"name", "title"}, nil, cat))
|
||||
refs = append(refs, o.refOf(m,
|
||||
refKeys.id, refKeys.name, refKeys.value, cat))
|
||||
}
|
||||
}
|
||||
if len(users) > 0 && len(refs) == 0 {
|
||||
|
||||
+50
-15
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ombi-mcp/internal/ombi"
|
||||
"ombi-mcp/internal/translate"
|
||||
@@ -604,36 +605,70 @@ func (o *op) projectCalendarEntry(m map[string]any) CalendarEntry {
|
||||
|
||||
// refOf projects one upstream object into a reference record using
|
||||
// the first present key from each candidate list.
|
||||
func refOf(m map[string]any, idKeys, nameKeys []string, val any, category string) Reference {
|
||||
func (o *op) refOf(m map[string]any, idKeys, nameKeys, valueKeys []string, category string) Reference {
|
||||
r := Reference{Category: category}
|
||||
var idRaw any
|
||||
for _, k := range idKeys {
|
||||
if s, ok := toStr(m[k]); ok && s != "" {
|
||||
r.ID = s
|
||||
idRaw = m[k]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var firstCorrupted string
|
||||
for _, k := range nameKeys {
|
||||
if s, ok := m[k].(string); ok && s != "" {
|
||||
r.Name = s
|
||||
break
|
||||
if !corrupted(s) {
|
||||
r.Name = s
|
||||
firstCorrupted = ""
|
||||
break
|
||||
}
|
||||
if firstCorrupted == "" {
|
||||
firstCorrupted = s
|
||||
}
|
||||
}
|
||||
}
|
||||
if val == nil {
|
||||
r.Value = firstScalar(m)
|
||||
} else {
|
||||
r.Value = val
|
||||
if r.Name == "" && firstCorrupted != "" {
|
||||
r.Name = firstCorrupted
|
||||
if o != nil {
|
||||
o.warnf("upstream %s label appears corrupted (id %q)", category, r.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(valueKeys) > 0 {
|
||||
r.Value = scalarAt(m, valueKeys)
|
||||
} else if idRaw != nil {
|
||||
r.Value = idRaw
|
||||
} else if r.Name != "" {
|
||||
r.Value = r.Name
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// firstScalar returns the first scalar property value in a map for
|
||||
// fallback reference values; iteration order makes this best-effort,
|
||||
// so callers should prefer explicit keys where known.
|
||||
func firstScalar(m map[string]any) any {
|
||||
for _, v := range m {
|
||||
switch v.(type) {
|
||||
case string, float64, bool:
|
||||
return v
|
||||
func corrupted(s string) bool {
|
||||
t := strings.TrimSpace(s)
|
||||
if t == "" {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsRune(s, '\uFFFD') {
|
||||
return true
|
||||
}
|
||||
for _, r := range t {
|
||||
if r != '?' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func scalarAt(m map[string]any, keys []string) any {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
switch v.(type) {
|
||||
case string, float64, bool, int, int64:
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -159,7 +159,7 @@ func TestMergeTVRequestState(t *testing.T) {
|
||||
},
|
||||
}
|
||||
parent := map[string]any{
|
||||
"id": 909,
|
||||
"id": 909,
|
||||
"tvDbId": 81189,
|
||||
"childRequests": []any{
|
||||
map[string]any{
|
||||
@@ -207,3 +207,88 @@ func TestMergeTVRequestStateNoMatch(t *testing.T) {
|
||||
t.Errorf("expected Requested=nil on no match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefOfDeterministicValue(t *testing.T) {
|
||||
o := &op{}
|
||||
m := map[string]any{
|
||||
"id": 53,
|
||||
"name": "Thriller",
|
||||
"logoPath": "/x",
|
||||
"enabled": false,
|
||||
}
|
||||
for i := 0; i < 50; i++ {
|
||||
r := o.refOf(m, []string{"id"}, []string{"name"}, nil, "genre")
|
||||
if r.ID != "53" {
|
||||
t.Fatalf("iteration %d: id = %q, want \"53\"", i, r.ID)
|
||||
}
|
||||
if r.Name != "Thriller" {
|
||||
t.Fatalf("iteration %d: name = %q, want \"Thriller\"", i, r.Name)
|
||||
}
|
||||
if r.Value != 53 {
|
||||
t.Fatalf("iteration %d: value = %v (%T), want 53", i, r.Value, r.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefOfValueKeysOverride(t *testing.T) {
|
||||
o := &op{}
|
||||
m := map[string]any{
|
||||
"id": 53,
|
||||
"name": "Thriller",
|
||||
"customVal": "override",
|
||||
}
|
||||
r := o.refOf(m, []string{"id"}, []string{"name"}, []string{"customVal"}, "genre")
|
||||
if r.Value != "override" {
|
||||
t.Fatalf("value = %v, want \"override\"", r.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefOfCorruptedNameSkip(t *testing.T) {
|
||||
o := &op{}
|
||||
m := map[string]any{
|
||||
"id": 1,
|
||||
"english_name": "?????",
|
||||
"name": "Clean",
|
||||
}
|
||||
r := o.refOf(m, []string{"id"}, []string{"english_name", "name"}, nil, "language")
|
||||
if r.Name != "Clean" {
|
||||
t.Fatalf("name = %q, want \"Clean\"", r.Name)
|
||||
}
|
||||
if len(o.warn) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", o.warn)
|
||||
}
|
||||
|
||||
o = &op{}
|
||||
m2 := map[string]any{
|
||||
"id": 2,
|
||||
"english_name": "Bad\uFFFDName",
|
||||
"name": "CleanAlternate",
|
||||
}
|
||||
r2 := o.refOf(m2, []string{"id"}, []string{"english_name", "name"}, nil, "language")
|
||||
if r2.Name != "CleanAlternate" {
|
||||
t.Fatalf("name = %q, want \"CleanAlternate\"", r2.Name)
|
||||
}
|
||||
if len(o.warn) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", o.warn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefOfAllCorruptedEmitsAndWarns(t *testing.T) {
|
||||
o := &op{}
|
||||
m := map[string]any{
|
||||
"id": "ky",
|
||||
"english_name": "?????",
|
||||
"name": "?????",
|
||||
}
|
||||
r := o.refOf(m, []string{"id"}, []string{"english_name", "name"}, nil, "language")
|
||||
if r.Name != "?????" {
|
||||
t.Fatalf("name = %q, want \"?????\"", r.Name)
|
||||
}
|
||||
if len(o.warn) == 0 {
|
||||
t.Fatalf("expected warning for corrupted name, got none")
|
||||
}
|
||||
expectedWarn := "upstream language label appears corrupted (id \"ky\")"
|
||||
if o.warn[0] != expectedWarn {
|
||||
t.Fatalf("warn = %q, want %q", o.warn[0], expectedWarn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func (o *op) refArray(raw []byte, idKeys, nameKeys []string, cat string) ([]Refe
|
||||
}
|
||||
items := make([]Reference, 0, len(arr))
|
||||
for _, m := range arr {
|
||||
items = append(items, refOf(m, idKeys, nameKeys, nil, cat))
|
||||
items = append(items, o.refOf(m, idKeys, nameKeys, nil, cat))
|
||||
}
|
||||
return capItems(o, items), nil
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func (o *op) refKeyword(a *ReferenceArgs) *ToolResult {
|
||||
if fail != nil {
|
||||
return fail
|
||||
}
|
||||
r := refOf(m, []string{"id"}, []string{"name"}, nil, "keyword")
|
||||
r := o.refOf(m, []string{"id"}, []string{"name"}, nil, "keyword")
|
||||
return o.ok(&ReferencePage{Kind: "reference_page",
|
||||
Items: []Reference{r}, Page: singlePage(1, "references")})
|
||||
}
|
||||
|
||||
@@ -142,9 +142,37 @@ func revisionOf(raw []byte) string {
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func isDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// serverIdentityLeaf reports whether key is a server record identity
|
||||
// field directly under /servers/<digits>.
|
||||
func serverIdentityLeaf(parentPath, key string) bool {
|
||||
switch key {
|
||||
case "id", "serverId", "machineIdentifier":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
rest, ok := strings.CutPrefix(parentPath, "/servers/")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return isDigits(rest)
|
||||
}
|
||||
|
||||
// flattenSettings walks a decoded settings document emitting one
|
||||
// scalar leaf per Change with escaped-JSON-Pointer-style names.
|
||||
// Excluded and secret-looking fields land in omitted, never values.
|
||||
// Excluded and secret-looking fields land in omitted, never values,
|
||||
// with a scoped exemption for server identity fields.
|
||||
func flattenSettings(v any, path string, out *[]Change, omitted *[]string) {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
@@ -155,7 +183,7 @@ func flattenSettings(v any, path string, out *[]Change, omitted *[]string) {
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
child := path + "/" + escapePointer(k)
|
||||
if excludedSettingsFields[k] || secretish(k) {
|
||||
if (excludedSettingsFields[k] || secretish(k)) && !serverIdentityLeaf(path, k) {
|
||||
*omitted = append(*omitted, child)
|
||||
continue
|
||||
}
|
||||
@@ -165,7 +193,7 @@ func flattenSettings(v any, path string, out *[]Change, omitted *[]string) {
|
||||
for i, e := range t {
|
||||
flattenSettings(e, fmt.Sprintf("%s/%d", path, i), out, omitted)
|
||||
}
|
||||
case string, float64, bool:
|
||||
case string, float64, bool, int, int64:
|
||||
name := path
|
||||
if name == "" {
|
||||
name = "/"
|
||||
|
||||
Reference in New Issue
Block a user