Build and publish / Test and build (darwin) (push) Successful in 1m43s
Build and publish / Test and build (linux) (push) Successful in 2m30s
Build and publish / Test and build (windows) (push) Successful in 3m5s
Build and publish / Build and publish Docker image (push) Successful in 1m46s
Implements the three roadmap feature sections:
- Browse: retrieve_item_children, retrieve_season_list, retrieve_episode_list;
search_for_item gains item_types (e.g. "Series") so shows are findable
without a selected library. Items now expose item_type, series_name, and
per-user played state/resume position.
- retrieve_next_episode: next_unplayed (Emby NextUp, resume counts) or latest
(most recent present episode; virtual placeholders skipped).
- retrieve_now_playing + set_subtitle/set_audio_track: stream listing and
track switching via SetSubtitleStreamIndex/SetAudioStreamIndex GeneralCommands,
with a PlayNow restart-at-position fallback for sessions that cannot switch
mid-play (DLNA/Chromecast bridges).
Conformance manifest grows to 34 call sites with three new documented spec
gaps (StartPositionTicks on PlayRequest, missing /Shows/{Id}/Episodes response
schema, undeclared Fields/EnableUserData on /Users/{UserId}/Items/{Id}).
165 lines
10 KiB
Markdown
165 lines
10 KiB
Markdown
# Emby endpoint conformance audit
|
||
|
||
This audit compares every upstream REST call made by `internal/emby` with the
|
||
Emby Server REST API OpenAPI document, version `4.10.0.40`, in
|
||
[`emby_openapi.yaml`](./emby_openapi.yaml). It is a static check of paths,
|
||
methods, parameters, request bodies, decoded response fields, and enum values;
|
||
it does not contact a live Emby server.
|
||
|
||
Run it with:
|
||
|
||
```sh
|
||
go test ./internal/emby/ -run TestSpecConformance -v
|
||
```
|
||
|
||
## Inventory
|
||
|
||
The manifest contains 34 call sites. `✓` means the call is clean against the
|
||
spec; `⚠` means it passes through a documented known issue.
|
||
|
||
| # | MCP tool(s) | Call site | Method and path | Operation ID | Status |
|
||
|---:|---|---|---|---|---|
|
||
| 1 | startup auth | `library.go:19` | `POST /Users/AuthenticateByName` | `postUsersAuthenticatebyname` | ✓ |
|
||
| 2 | shutdown | `library.go:33` | `POST /Sessions/Logout` | `postSessionsLogout` | ✓ |
|
||
| 3 | retrieve_user_list | `users.go:20` | `GET /Users/{Id}` | `getUsersById` | ✓ |
|
||
| 4 | retrieve_user_list | `users.go:25` | `GET /Users/Public` | `getUsersPublic` | ✓ |
|
||
| 5 | retrieve_library_list, select_library | `library.go:39` | `GET /Library/MediaFolders` | `getLibraryMediafolders` | ✓ |
|
||
| 6 | retrieve_genre_list | `library.go:58` | `GET /Genres` | `getGenres` | ✓ |
|
||
| 7 | search_for_item, retrieve_next_episode (series resolve) | `items.go:135` | `GET /Users/{UserId}/Items` | `getUsersByUseridItems` | ⚠ |
|
||
| 8 | retrieve_item_children | `shows.go:28` | `GET /Users/{UserId}/Items` | `getUsersByUseridItems` | ⚠ |
|
||
| 9 | retrieve_season_list | `shows.go:48` | `GET /Shows/{Id}/Seasons` | `getShowsByIdSeasons` | ⚠ |
|
||
| 10 | retrieve_episode_list | `shows.go:75` | `GET /Shows/{Id}/Episodes` | `getShowsByIdEpisodes` | ⚠ |
|
||
| 11 | retrieve_next_episode (next_unplayed) | `shows.go:97` | `GET /Shows/NextUp` | `getShowsNextup` | ⚠ |
|
||
| 12 | retrieve_next_episode (latest) | `shows.go:120` | `GET /Shows/{Id}/Episodes` | `getShowsByIdEpisodes` | ⚠ |
|
||
| 13 | retrieve_playlist_list, create_playlist, modify_playlist_name | `playlists.go:76` | `GET /Users/{UserId}/Items` | `getUsersByUseridItems` | ✓ |
|
||
| 14 | retrieve_playlist_list | `playlists.go:99` | `GET /Users/ItemAccess` | `getUsersItemaccess` | ⚠ |
|
||
| 15 | retrieve_playlist_items | `playlists.go:120` | `GET /Playlists/{Id}/Items` | `getPlaylistsByIdItems` | ⚠ |
|
||
| 16 | create_playlist | `playlists.go:175` | `POST /Playlists` | `postPlaylists` | ✓ |
|
||
| 17 | modify_playlist_name, create_playlist (overview) | `playlists.go:211` | `GET /Users/{UserId}/Items/{Id}` | `getUsersByUseridItemsById` | ✓ |
|
||
| 18 | modify_playlist_name, create_playlist (overview) | `playlists.go:223` | `POST /Items/{ItemId}` | `postItemsByItemid` | ✓ |
|
||
| 19 | add_items_to_playlist, create_playlist | `playlists.go:230` | `POST /Playlists/{Id}/Items` | `postPlaylistsByIdItems` | ✓ |
|
||
| 20 | remove_items_from_playlist | `playlists.go:242` | `POST /Playlists/{Id}/Items/Delete` | `postPlaylistsByIdItemsDelete` | ✓ |
|
||
| 21 | reorder_items_on_playlist | `playlists.go:248` | `POST /Playlists/{Id}/Items/{ItemId}/Move/{NewIndex}` | `postPlaylistsByIdItemsByItemidMoveByNewindex` | ✓ |
|
||
| 22 | share_playlist_public | `playlists.go:257` | `POST /Items/{Id}/MakePublic` | `postItemsByIdMakepublic` | ✓ |
|
||
| 23 | stop_sharing_playlist | `playlists.go:259` | `POST /Items/{Id}/MakePrivate` | `postItemsByIdMakeprivate` | ✓ |
|
||
| 24 | share_playlist_user_access | `playlists.go:269` | `POST /Items/Access` | `postItemsAccess` | ✓ |
|
||
| 25 | retrieve_player_list | `sessions.go:41` | `GET /Sessions` | `getSessions` | ✓ |
|
||
| 26 | retrieve_player_queue | `sessions.go:146` | `GET /Sessions/PlayQueue` | `getSessionsPlayqueue` | ✓ |
|
||
| 27 | retrieve_now_playing, set_subtitle, set_audio_track | `sessions.go:183` | `GET /Sessions` | `getSessions` | ✓ |
|
||
| 28 | set_subtitle, set_audio_track | `sessions.go:202` | `POST /Sessions/{Id}/Command` | `postSessionsByIdCommand` | ✓ |
|
||
| 29a | control_media_player (PlayNow) | `sessions.go:260` | `POST /Sessions/{Id}/Playing` | `postSessionsByIdPlaying` | ⚠ |
|
||
| 29b | set_subtitle, set_audio_track (restart fallback) | `sessions.go:227` | `POST /Sessions/{Id}/Playing` | `postSessionsByIdPlaying` | ⚠ |
|
||
| 30 | control_media_player (other commands) | `sessions.go:281` | `POST /Sessions/{Id}/Playing/{Command}` | `postSessionsByIdPlayingByCommand` | ✓ |
|
||
| 31 | retrieve_now_playing, set_subtitle, set_audio_track | `items.go:165` | `GET /Users/{UserId}/Items/{Id}` | `getUsersByUseridItemsById` | ⚠ |
|
||
| 32 | http auth (token→user) | `sessions.go:121` | `GET /Sessions` | `getSessions` | ✓ |
|
||
| 33 | http auth (X-Emby-User-Id) | `users.go:42` | `GET /Users/{Id}` | `getUsersById` | ✓ |
|
||
| 34 | startup check (http) | `system.go:16` | `GET /System/Info/Public` | `getSystemInfoPublic` | ✓ |
|
||
|
||
## Findings
|
||
|
||
### 1. SPEC-GAP — `GET /Users/ItemAccess` does not declare `ItemId`
|
||
|
||
The Go client sends `ItemId` to restrict the access lookup to the current
|
||
playlist, but the OpenAPI operation lists only `IsHidden`, `IsDisabled`,
|
||
`StartIndex`, `Limit`, `NameStartsWithOrGreater`, and `SortOrder`. The upstream
|
||
Python implementation contains a hotfix that adds `item_id` to this exact
|
||
request, which is independent evidence that the server endpoint accepts it.
|
||
|
||
The test allowlists the missing parameter and fails if the spec later declares
|
||
it without the registry being updated. Client behavior is unchanged.
|
||
|
||
### 2. CODE-DEVIATION — PlayNow sends `PlayCommand` in the JSON body
|
||
|
||
For `POST /Sessions/{Id}/Playing`, the client sends
|
||
`playRequest{PlayCommand, ControllingUserId}`. The spec’s `PlayRequest` schema
|
||
contains `ControllingUserId`, `SubtitleStreamIndex`, `AudioStreamIndex`,
|
||
`MediaSourceId`, and `StartIndex`, but not `PlayCommand`. `ItemIds` and
|
||
`PlayCommand` are correctly sent as required query parameters.
|
||
|
||
This is report-only. A later cleanup could remove `PlayCommand` from the body
|
||
struct; no client behavior is changed by this audit.
|
||
|
||
### 3. SPEC-QUIRK — `MediaStream.Extradata` spelling
|
||
|
||
The spec spells the subtitle payload property `Extradata`; Emby JSON emits
|
||
`ExtraData`. Go’s case-insensitive JSON field matching means the existing
|
||
`ExtraData` field still decodes the server value. The spelling is therefore
|
||
documented as a spec quirk rather than treated as a client defect.
|
||
|
||
### 4. SPEC-QUIRK — PlayNow `ItemIds` is typed as `array<int64>`
|
||
|
||
The `/Sessions/{Id}/Playing` operation describes `ItemIds` as an integer array,
|
||
while its description says the values are comma-delimited and real Emby item
|
||
IDs are strings. The client sends the expected comma-separated string query
|
||
value. The test keeps a stale guard for this spec shape and does not alter the
|
||
client encoding.
|
||
|
||
### 5. SPEC-GAP — `PlayRequest` omits `StartPositionTicks`
|
||
|
||
`PlayNowWithOptions` sends `StartPositionTicks` so `set_subtitle` /
|
||
`set_audio_track` can restart playback at the current position when a session
|
||
cannot switch streams mid-playback. The spec’s `PlayRequest` schema omits the
|
||
field, but Emby has accepted it historically (it is present in real clients’
|
||
play requests). The field is allowlisted; the stale guard fails if the spec
|
||
later declares it.
|
||
|
||
### 6. SPEC-GAP — `/Shows/{Id}/Episodes` has no response schema
|
||
|
||
The operation declares parameters but no JSON 200-response schema, so the
|
||
episode-list rows cannot be field-checked. The sibling
|
||
`/Shows/{Id}/Seasons` operation returns `QueryResult_BaseItemDto`, which is
|
||
what the client decodes. The gap is allowlisted; respField checks are skipped
|
||
for these rows.
|
||
|
||
### 7. SPEC-GAP — `/Users/{UserId}/Items/{Id}` omits `Fields`/`EnableUserData`
|
||
|
||
`GetItem` sends both parameters to fetch media streams and per-user watch
|
||
state for `retrieve_now_playing`; the spec’s parameter list does not declare
|
||
them. Real Emby accepts them (they are standard item-query params). The
|
||
parameters are allowlisted.
|
||
|
||
## Known-issues registry
|
||
|
||
The registry lives in `internal/emby/spec_conformance_test.go`. Each entry has
|
||
a machine-checkable predicate. The test fails on an unexpected violation and
|
||
also fails when a known issue’s predicate no longer holds, forcing the entry
|
||
to be removed or revised after a spec/client update.
|
||
|
||
| ID | Kind | Evidence | Action |
|
||
|---|---|---|---|
|
||
| `SPEC-GAP-ITEMACCESS-ITEMID` | spec-gap | Python hotfix `Emby.MCP/hotfixes/emby/user_service_api.py` adds `item_id` to `GET /Users/ItemAccess`. | Allowlist; keep client behavior. |
|
||
| `CODE-DEVIATION-PLAYREQUEST-PLAYCOMMAND` | code-deviation | `internal/emby/sessions.go:168` includes `PlayCommand`; `PlayRequest` omits it. | Report only; consider a later cleanup. |
|
||
| `SPEC-QUIRK-MEDIASTREAM-EXTRADATA` | spec-quirk | Spec has `Extradata`; server JSON/client field uses `ExtraData`. | Document only. |
|
||
| `SPEC-QUIRK-PLAYING-ITEMIDS-TYPE` | spec-quirk | Spec says `array<int64>` but describes comma-delimited IDs; client sends strings. | Document only; retain string query encoding. |
|
||
| `SPEC-GAP-PLAYREQUEST-STARTPOSITIONTICKS` | spec-gap | `playRequest{StartPositionTicks}` sent by `PlayNowWithOptions`; `PlayRequest` omits it. Real Emby accepts it for resume-position restarts. | Allowlist; keep client behavior. |
|
||
| `SPEC-GAP-EPISODES-RESPONSE-SCHEMA` | spec-gap | `GET /Shows/{Id}/Episodes` has no JSON 200-response schema (sibling `Seasons` returns `QueryResult_BaseItemDto`). | Allowlist; response fields unchecked on those rows. |
|
||
| `SPEC-GAP-ITEMBYID-USERDATA-PARAMS` | spec-gap | `GET /Users/{UserId}/Items/{Id}` omits `Fields`/`EnableUserData` params, which real Emby accepts. | Allowlist; keep client behavior. |
|
||
|
||
## Scope and adjacent endpoints
|
||
|
||
The manifest records the parameter names the client may send, including
|
||
optional parameters that are conditional at runtime. The test does not require
|
||
the client to send every optional parameter in the spec. The authentication
|
||
header is also out of the manifest because `Client.do` adds
|
||
`X-Emby-Authorization` to every request centrally.
|
||
|
||
The spec contains adjacent endpoints that are not used by this client:
|
||
|
||
* `DELETE /Playlists/{Id}/Items` exists alongside the client’s
|
||
`POST /Playlists/{Id}/Items/Delete` removal call; both are present in the
|
||
spec.
|
||
* `POST /Sessions/Playing` is a playback check-in endpoint and is not used;
|
||
the client uses the session-control endpoints under
|
||
`/Sessions/{Id}/Playing`.
|
||
|
||
The YAML and JSON copies should remain synchronized. At audit time their SHA256
|
||
hashes were:
|
||
|
||
```text
|
||
emby_openapi.yaml 7738d8eefadad7a2646781a7907fdd0373348fe19ae856d3f6f69dfc2987f27f
|
||
emby_openapi.json 55bc60c71b3d5df30d772f7ec4496e0e3f3fa371947691eeb62829a4ea91d530
|
||
```
|
||
|
||
They are different serializations/copies rather than byte-identical files, so
|
||
changes to one should be reviewed against the other.
|