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}).
10 KiB
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. 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:
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}/Itemsexists alongside the client’sPOST /Playlists/{Id}/Items/Deleteremoval call; both are present in the spec.POST /Sessions/Playingis 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:
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.