Fix streaming: use Emby's PlaybackInfo instead of a hand-built m3u8 URL
All checks were successful
Publier l'image Docker / publish (push) Successful in 6s

Real error from the field: "Value cannot be null. (Parameter 'key')" -
Emby's own server (.NET) throwing an ArgumentNullException. The
/api/catalog/stream/:itemId/master.m3u8 route was hand-building
/Videos/{id}/master.m3u8?VideoCodec=h264&AudioCodec=aac directly,
missing whatever parameter (MediaSourceId/PlaySessionId/DeviceId are
the likely culprits) Emby's server needs for an internal lookup.

Fixed by calling GET /Items/{id}/PlaybackInfo?UserId=... first and
using the returned MediaSource's own TranscodingUrl - the same thing
real Emby clients do, rather than guessing the URL shape ourselves.
Falls back to a hand-built URL only if no TranscodingUrl comes back,
now at least carrying MediaSourceId/PlaySessionId/UserId from that
response instead of nothing.

docs/architecture.md updated: the real bug and fix, plus a known
limitation this doesn't yet handle (a direct-play source with no
TranscodingUrl at all - hls.js has nothing to fall back to for that
case yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-13 21:59:54 +02:00
parent 07613c1cec
commit 1891e87c83
2 changed files with 56 additions and 9 deletions

View file

@ -152,11 +152,20 @@ point is confirmed:
- **`/Users/{userId}` for token validation** was dropped — the proxy - **`/Users/{userId}` for token validation** was dropped — the proxy
redesign no longer needs it at all (sessions are validated once at login redesign no longer needs it at all (sessions are validated once at login
and cached server-side, not re-checked against Emby per-request). and cached server-side, not re-checked against Emby per-request).
- **`/Videos/{id}/master.m3u8`** for HLS playback, with - **Streaming**: the first version hand-built
`VideoCodec=h264&AudioCodec=aac` forcing a broadly-compatible transcode `/Videos/{id}/master.m3u8?VideoCodec=h264&AudioCodec=aac` directly, and it
target. Works for basically anything Emby can transcode, but is never the broke in the field: Emby's server threw `ArgumentNullException: Value
most efficient path (no direct play, no bitrate/quality negotiation) — cannot be null. (Parameter 'key')` — almost certainly an internal
fine for v1, worth revisiting once the basic flow works. dictionary keyed by something that URL never supplied (`MediaSourceId`,
`PlaySessionId`, `DeviceId` are the usual suspects). Fixed by calling
`GET /Items/{id}/PlaybackInfo?UserId=...` first and using the
`MediaSource`'s own `TranscodingUrl` — the same thing real Emby clients do
— falling back to a hand-built URL (now at least carrying
`MediaSourceId`/`PlaySessionId`/`UserId` from that response) only if no
`TranscodingUrl` comes back. Not yet confirmed against a real response:
whether `TranscodingUrl` is reliably present, and whether it's always HLS
(a direct-play-eligible source might not transcode at all, which `hls.js`
on the client wouldn't handle - see Known limitations).
- **HLS playlist rewriting** (`rewritePlaylist` in `embyProxy.js`) — the - **HLS playlist rewriting** (`rewritePlaylist` in `embyProxy.js`) — the
logic is straightforward (rewrite every non-`#` line) but has only been logic is straightforward (rewrite every non-`#` line) but has only been
reasoned about, never run against a playlist Emby actually generated. If reasoned about, never run against a playlist Emby actually generated. If
@ -211,8 +220,12 @@ response shapes for `/Views` and library items are not (see below).
## Known limitations ## Known limitations
- Search-only catalog (see Non-goals) — no way to browse a show's episode - Library tabs show top-level items only — no drilling into a Series'
list yet. seasons/episodes yet (a Series tile links out to Emby instead, see above).
- Playback always goes through `hls.js` against whatever Emby's
`PlaybackInfo` suggests. If that ever comes back as a direct-play source
with no `TranscodingUrl` (no transcoding needed/possible), the client has
no fallback path yet - it'll try to hand hls.js something that isn't HLS.
- One item per party; no shared queue. - One item per party; no shared queue.
- A party member needs a browser tab open and connected for sync to apply — - A party member needs a browser tab open and connected for sync to apply —
there's no "catch up automatically when you come back" beyond whatever there's no "catch up automatically when you come back" beyond whatever

View file

@ -154,8 +154,42 @@ app.get('/api/catalog/image/:itemId', async (req, res) => {
app.get('/api/catalog/stream/:itemId/master.m3u8', async (req, res) => { app.get('/api/catalog/stream/:itemId/master.m3u8', async (req, res) => {
const session = sessionFromRequest(req); const session = sessionFromRequest(req);
if (!session) { res.status(401).end(); return; } if (!session) { res.status(401).end(); return; }
const params = new URLSearchParams({ VideoCodec: 'h264', AudioCodec: 'aac' }); const itemId = req.params.itemId;
await proxyEmbyResponse(req, res, `/Videos/${req.params.itemId}/master.m3u8?${params}`, session.embyToken, req.params.itemId);
// Ask Emby how it wants this played rather than hand-building a
// /Videos/{id}/master.m3u8 URL ourselves: the first version did that with
// only VideoCodec/AudioCodec, and Emby's server threw "Value cannot be
// null (Parameter 'key')" - almost certainly an internal dictionary
// lookup (MediaSourceId/PlaySessionId/DeviceId) that URL never supplied.
// PlaybackInfo returns a MediaSource with its own ready-to-use
// TranscodingUrl carrying whatever Emby actually needs.
const infoRes = await embyFetch(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session.embyToken);
if (!infoRes.ok) {
res.status(502).json({ error: 'Impossible de préparer la lecture de ce titre.' });
return;
}
const info = await infoRes.json();
const source = (info.MediaSources || [])[0];
if (!source) {
res.status(502).json({ error: 'Aucune source de lecture disponible pour ce titre.' });
return;
}
// TODO(verify): TranscodingUrl not confirmed present for every source
// type/Emby version (e.g. a source Emby considers direct-playable may not
// have one at all) - the hand-built fallback below now at least supplies
// the parameters PlaybackInfo reveals (MediaSourceId, PlaySessionId,
// UserId) instead of guessing, but is still a fallback, not the primary path.
const target = source.TranscodingUrl
|| `/Videos/${itemId}/master.m3u8?${new URLSearchParams({
UserId: session.userId,
MediaSourceId: source.Id || '',
PlaySessionId: info.PlaySessionId || '',
VideoCodec: 'h264',
AudioCodec: 'aac'
})}`;
await proxyEmbyResponse(req, res, target, session.embyToken, itemId);
}); });
// Everything an HLS playlist references (variant playlists, segments) comes // Everything an HLS playlist references (variant playlists, segments) comes