main() sliced argv at the first occurrence of a known subcommand name, so a value-flag pair whose value names a subcommand (e.g. login's --server given `search`) made argparse dispatch the wrong subparser: `jellyfin --server search browse ...` errored inside the `search` sub-parser instead of running browse. Dispatch now splits such misplaced pairs out of the top-level argv (find_subcommand_token + split_misplaced_value_pairs) and re-attaches them to the command tail, where each handler's parse_known_args already tolerates unknown flags. A properly placed occurrence of the same flag later in the tail still wins. Any other pre-command token (unknown flags, stray positionals, `--`, dangling value flags) is untouched, so argparse errors stay byte-identical to the pre-hardening CLI. Also dedupe the twice-repeated "Bundled CLI --dry-run and exit-code contract" section in references/worked-recipes.md (user-testing round 1 finding); content merged into one section. Adds DispatchHardeningTests: the mis-slice scenario for every subcommand shape, clean-argv dispatch for all 11 subcommands, flag priority, argparse-owned error paths, and unit pinning of both helper return-value tables. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
8.9 KiB
Jellyfin worked recipes
Multi-step workflows against a real server. Every stage consumes the previous stage's
output; field names and types match the endpoint catalog. Constants: BASE =
http://<host>:8096 (official default port). Wire examples use PascalCase properties; your
client should normalize casing (see the gotchas guide).
Recipe 1 — Log in, capture identity, list what's new for that user
The full pre-token → token → user-scoped-read sequence:
1. (probe) GET /System/Info/Public # no auth → ServerName, Version
2. POST /Users/AuthenticateByName
Authorization: MediaBrowser Client="my-cli", Device="terminal",
DeviceId="dev-1", Version="1.0.0" # COMPLETE header, no Token yet
{"Username": "alice", "Pw": "secret"}
→ AuthenticationResult
3. USER_ID = .User.Id TOKEN = .AccessToken # hyphenated UUID; both strings
4. Subsequent calls:
Authorization: MediaBrowser Client="my-cli", Device="terminal",
DeviceId="dev-1", Version="1.0.0", Token="{TOKEN}"
5. GET /Items/Latest?userId={USER_ID}&limit=20&includeItemTypes=Movie,Series,Episode
→ 200 BARE ARRAY of BaseItemDto (NOT a wrapper) # shape branch here
One-liner with curl + jq:
AUTH='Authorization: MediaBrowser Client="my-cli", Device="terminal", DeviceId="dev-1", Version="1.0.0"'
res=$(curl -s -X POST -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"Username":"alice","Pw":"secret"}' "$BASE/Users/AuthenticateByName")
user_id=$(jq -r '.User.Id' <<<"$res")
token=$(jq -r '.AccessToken' <<<"$res")
curl -s -H "Authorization: MediaBrowser Token=\"$token\"" \
"$BASE/Items/Latest?userId=$user_id&limit=20" | jq -r '.[] | .Name'
Failure branches: step 2 without the full MediaBrowser header → 400 Error processing request.; wrong password → 401; server restarting → 503 + Retry-After (retry).
With the bundled CLI, steps 2–5 collapse to env vars: export JELLYFIN_URL,
JELLYFIN_API_KEY (or JELLYFIN_TOKEN), JELLYFIN_USER_ID; then
scripts/jellyfin recent --json. Post-login requests carry the token in exactly one
channel — the Token= parameter of the MediaBrowser Authorization header (verified by
the suite's request-capture test); the legacy X-Emby-Token header is not co-sent. The
login path itself is available as scripts/jellyfin login --username alice (reads
JELLYFIN_PASSWORD interactively or via --password/--password-stdin), which prints the
captured user_id/access_token for exporting.
Recipe 2 — Libraries → paged browse of one collection
1. GET /UserViews?userId={USER_ID} # token-auth
→ {Items: [{Id, Name, CollectionType: "movies"|"tvshows"|..., ...}], TotalRecordCount}
2. VIEW_ID = .Items[] | select(.Name == "Movies") | .Id
3. Page loop (both guards — counts can go stale mid-scan):
start = 0; PAGE = 100
loop:
GET /Items?userId={USER_ID}&parentId={VIEW_ID}&recursive=true
&includeItemTypes=Movie&sortBy=SortName&sortOrder=Ascending
&startIndex={start}&limit={PAGE}
→ {Items: [...], TotalRecordCount: N, StartIndex: start}
emit .Items; start += len(Items)
stop when len(Items) == 0 OR start >= TotalRecordCount
Why /UserViews and not /Library/MediaFolders: MediaFolders is admin-elevated
(RequiresElevation) — a non-admin token gets 403. UserViews is the per-user library list
for any token.
scripts/jellyfin libraries --json | jq -r '.libraries[] | [.name, .id] | @tsv'
scripts/jellyfin browse --library-id "$VIEW_ID" --type Movie --limit 100 --start-index 0 --json \
| jq -r '.items[] | [.name, .year] | @tsv'
Recipe 3 — Search → seasons → episodes walk
1. FIND SERIES:
GET /Search/Hints?searchTerm=breaking&includeItemTypes=Series&limit=20&userId={USER_ID}
→ {SearchHints: [{Id (read .Id; .ItemId is the deprecated twin), Name, Type, ...}]}
Fuller DTOs alternative:
GET /Items?userId=..&recursive=true&includeItemTypes=Series&searchTerm=..&fields=Overview
2. SEASONS:
GET /Shows/{SERIES_ID}/Seasons?userId={USER_ID}&fields=Overview
→ {Items: [{Id, Name, IndexNumber (0 = specials), Type: "Season"}]}
3. EPISODES per season (prefer seasonId over numeric season — numbers shift):
GET /Shows/{seriesId}/Episodes?userId={USER_ID}&seasonId={SEASON_ID}&sortBy=AiredEpisodeOrder
→ {Items: [{Name, IndexNumber, ParentIndexNumber, UserData.PlayedPercentage, RunTimeTicks}]}
4. Whole-series flat option (skip seasons):
GET /Shows/{seriesId}/Episodes?userId={USER_ID}&startIndex=0&limit=100
5. NEXT UP for one series:
GET /Shows/NextUp?userId={USER_ID}&seriesId={SERIES_ID}&limit=10
scripts/jellyfin search --query "breaking bad" --type Series --json | jq -r '.results[0].id'
scripts/jellyfin item --id "$SERIES_ID" --user-id "$USER_ID" --json
scripts/jellyfin next-up --user-id "$USER_ID" --limit 10 --json | jq -r '.items[] | .series'
Recipe 4 — server health → stats → next-up evening plan
1. GET /System/Info/Public # no auth: is the server up? which version?
2. GET /System/Info # token: OperatingSystem, Version (full)
3. GET /Items/Counts?userId={USER_ID} # MovieCount, SeriesCount, EpisodeCount, SongCount
4. GET /Shows/NextUp?userId={USER_ID}&limit=5 # always pass userId (NextUp ≤10.8 crashes without)
→ {Items: [Episode...], TotalRecordCount}
scripts/jellyfin info --json
scripts/jellyfin stats --json | jq -r '.movies, .episodes'
scripts/jellyfin next-up --limit 5 --json
Recipe 5 — find an item id → full details → image URL
1. scripts/jellyfin search --query "dune" --type Movie --json → .results[0].id
2. GET /Items/{ITEM_ID}?userId={USER_ID}
→ BaseItemDto: Overview, Genres, CommunityRating, OfficialRating, RunTimeTicks,
ProductionYear, ImageTags.Primary, BackdropImageTags[], ProviderIds
3. Image URL:
{BASE}/Items/{ITEM_ID}/Images/Primary?maxWidth=300&tag={ImageTags.Primary}
# 404 here means "no such image" — fall back to Thumb/Backdrop, not an error
RunTimeTicks are 100-nanosecond ticks (divide by 600,000,000 for minutes).
Bundled CLI --dry-run and exit-code contract
The CLI's dry-run plans are pinned by its offline test suite (scripts/test_jellyfin_cli.py),
so jq keys match tested reality exactly. Every plan carries:
{ "dry_run": true, "path": "/Items/Latest", "params": { "userId": null, "limit": 10 } }
dry_run(bool, always true),path(string) andparams(object) appear on every command plan;logininstead emitspath: "/Users/AuthenticateByName",server,username,authorization_header, andpre_token_header: true(itsauthorization_headeris the complete pre-token MediaBrowser header, noToken=segment);infocomposes arequestsarray of{path, params}steps instead of a singlepath/paramspair.paramsmirrors the exact query the live call would send (userIdis JSONnullwhen not supplied).
Exit codes: 0 on success (including dry-run previews), 1 on CLI errors (missing
credentials, unreachable server, API 4xx/5xx, missing required --user-id), 2 on
argparse misuse such as --movies --episodes together or a missing required flag.
Cross-version-safe baseline (derive your own recipes from these rules)
- Speak modern auth (
Authorization: MediaBrowser ...) and put the token in exactly ONE channel per request (itsToken=parameter). Never co-send the legacyX-Emby-Tokenheader with it — precedence across channels is unspecified; if a legacy-only server needs the old header, substitute it there, do not stack channels. - Send a complete pre-token header everywhere — zero cost, avoids the 400-on-login trap.
- Always send explicit
userIdon/Items*,/UserViews,/Shows/*,/Items/Latest. - Resolve identity once:
USER_ID = AuthenticationResult.User.Id(fallback/Users/Mewith a user token; NEVER with an API key). - Honor 503 +
Retry-Afteron any endpoint; never retry 400-class. - Probe
GET /System/Info/Publicfirst; branch on semver (NextUp userId <10.9; legacy header availability ≥10.11 config). - Branch on shape: wrapper object vs bare array (
/Items/Latest) vsSearchHintskey. - DeviceId per profile — one token per
(DeviceId, user)pair; re-login revokes the pair's previous token.
Sources
Endpoint semantics, pagination, and auth sequencing inherit citations from the auth, endpoint, and gotchas references (all fetched this session): https://api.jellyfin.org/ · https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json · https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f · https://mintlify.wiki/jellyfin/jellyfin/api/authentication/overview · https://kotlin-sdk.jellyfin.org/guide/authentication.html · https://github.com/jellyfin/jellyfin (ItemsController.cs, TvShowsController.cs, UserViewsController.cs, LibraryController.cs, SessionManager.cs)