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
redesign no longer needs it at all (sessions are validated once at login
and cached server-side, not re-checked against Emby per-request).
- **`/Videos/{id}/master.m3u8`** for HLS playback, with
`VideoCodec=h264&AudioCodec=aac` forcing a broadly-compatible transcode
target. Works for basically anything Emby can transcode, but is never the
most efficient path (no direct play, no bitrate/quality negotiation) —
fine for v1, worth revisiting once the basic flow works.
- **Streaming**: the first version hand-built
`/Videos/{id}/master.m3u8?VideoCodec=h264&AudioCodec=aac` directly, and it
broke in the field: Emby's server threw `ArgumentNullException: Value
cannot be null. (Parameter 'key')` — almost certainly an internal
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
logic is straightforward (rewrite every non-`#` line) but has only been
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
- Search-only catalog (see Non-goals) — no way to browse a show's episode
list yet.
- Library tabs show top-level items only — no drilling into a Series'
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.
- 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

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) => {
const session = sessionFromRequest(req);
if (!session) { res.status(401).end(); return; }
const params = new URLSearchParams({ VideoCodec: 'h264', AudioCodec: 'aac' });
await proxyEmbyResponse(req, res, `/Videos/${req.params.itemId}/master.m3u8?${params}`, session.embyToken, req.params.itemId);
const itemId = 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