watchparty/docs/architecture.md
Nicolas 4b2d4822ef
All checks were successful
Publier l'image Docker / publish (push) Successful in 14s
Fix language/subtitle selection: force our pick onto Emby's negotiation
Bug report: picking an audio language or subtitle track in the player's
settings menu had no effect. First real-world bug reachable only once
streaming itself was already confirmed working end to end (this is that
confirmation, per docs/architecture.md).

Root cause, reasoned from how Emby's API actually works (no way to verify
directly - see below): real Emby web clients negotiate track selection via
a POST /Items/{id}/PlaybackInfo body with a DeviceProfile; this app only
ever sent AudioStreamIndex/SubtitleStreamIndex as bare GET query params,
which PlaybackInfo's negotiation likely doesn't apply the same way -  so
the TranscodingUrl it returned just reflected Emby's own default track,
regardless of what was actually requested. Separately, even a correctly
selected subtitle has a second silent-failure path: Emby can deliver text
subtitles burned into the video (SubtitleMethod=Encode) or as their own
selectable HLS rendition (SubtitleMethod=Hls) - this player's hls.js setup
never wires up subtitleTrack/subtitleTracks, so a Hls-method subtitle would
sit in the manifest unused.

server/src/index.js: new applyPlaybackOverrides forces
AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate onto whichever URL
the /master.m3u8 route ends up with - Emby's own TranscodingUrl, or the
hand-built fallback (which, before this fix, dropped these three params
entirely - a second, independent way the same symptom could happen).
Picking a subtitle also forces SubtitleMethod=Encode; picking "Aucun"
deletes both SubtitleStreamIndex and SubtitleMethod rather than just not
adding one, so it also cancels a subtitle Emby's negotiation had defaulted
to on its own. The negotiation call no longer needs to be trusted to apply
these params - it only needs to return a playable MediaSource, since every
control this app's UI exposes gets forced afterward regardless.

Verified as far as possible without real Emby access:
applyPlaybackOverrides is pure URLSearchParams manipulation, unit-tested in
isolation (adding a subtitle, removing one Emby had defaulted to,
re-sending an unchanged selection across an audio-only change, quality
override, the no-selection-yet initial load) - all pass. Confirmed
client-side too, by mocking Hls to capture what loadSource was actually
called with: initial load sends the default track's index, each dropdown
change re-sends a full reload with exactly the accumulated params expected.
What's not verified is the real Emby round trip - that needs a live
retest, which this environment has no credentials to do.

docs/architecture.md has the full writeup, and its "Assumptions to verify"
section got a broader pass while in there: several items long-flagged as
"not yet exercised against the real server" are now implicitly confirmed
by everything that's worked in real usage so far (response field names,
auth headers, playlist rewriting, basic HLS playback) - narrowed the list
down to what's genuinely still unconfirmed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:35:22 +02:00

583 lines
35 KiB
Markdown

# Architecture
## Goals
- Watch the same title, in sync, with other users of the same Emby server.
- Use Emby as the media source (catalog + streaming) without requiring
changes to Emby itself or its official clients.
- Access gated to people with an Emby account on this server — no anonymous
use, no separate identity system.
## Non-goals
- Chat/reactions — deliberately out of scope.
- OIDC/SSO — an OIDC provider (LemonLDAP::NG) is available in principle, but
the explicit choice was Emby-only auth: simpler, no extra infra dependency,
and everyone already has an Emby account.
- Full library navigation (Shows → Seasons → Episodes) in v1 — search-based
catalog only, to keep the first version achievable. Revisit if browsing
turns out to matter more than searching in practice.
## Why this replaced an Emby plugin
The first attempt (still in [`src/WatchParty/`](../src/WatchParty/), kept for
reference) was a real Emby Server plugin: sync via `ISessionManager`'s
existing remote-control commands, a settings page via `IHasWebPages`, and —
after a fair amount of reverse-engineering — a real user-facing page
integrated into Emby's own web client menu (`PluginPageInfo.EnableInUserMenu`,
undocumented but confirmed working; required a `data-controller` AMD module
to satisfy Emby's modern `ViewManager`, found by reading the target server's
actual `viewmanager.js`).
That plugin builds and installs correctly. What it hit a hard wall on: **a
button inside the video player itself**, or on an item's detail page. An
Emby developer confirmed on their own forum that plugins have no API for
this ("It's currently not possible for a plugin to do this"), and nothing
found in research since suggests that's changed. The only way to get a
button into Emby's own player would be patching Emby's shipped web client
files directly inside the Docker container — outside the plugin entirely,
guaranteed to break on every Emby image update, and requiring manual
reapplication each time. That trade-off wasn't worth it.
Building a standalone app sidesteps the question entirely: it's our own
player, so there's no "how do I add a button to someone else's UI" problem
in the first place, and sync can be tighter than nudging Emby's
remote-control commands ever allowed (see below).
Full trace of the plugin investigation (real errors, real fixes, sources
consulted) is in the git history of this file and of `src/WatchParty/` —
worth reading if a future Emby SDK update ever makes in-player integration
possible and this gets revisited.
## Components
```
server/ Node.js + Express + Socket.IO. Owns party/room state and the
realtime sync channel. Never touches a media byte - see "Media
never flows through our backend" below.
client/ Static frontend, plain JS + ES modules, no build step (same bias
as the earlier plugin's control panel). Talks to two different
things and must not confuse them:
- Emby directly (embyApi.js): catalog, images, streaming.
- Our own backend (party.js): create/join/leave a party, and
the Socket.IO connection for realtime sync.
```
### Auth and Emby access: the server proxies everything
Revised after the first version: the browser used to call Emby directly
(catalog, images, streaming) and only used `server/` for party coordination.
That hit real CORS problems in practice (Emby/its reverse proxy don't
answer cross-origin preflights the way that needed), and separately wasn't
the wanted shape anyway — the browser holding a real Emby access token at
all was more exposure than necessary. Current design:
- The browser sends a username/password **once**, to
`POST /api/auth/login` on **our own backend** — never to Emby directly.
- Our backend (`server/src/embyAuth.js`) calls Emby's
`POST /Users/AuthenticateByName` itself, using `EMBY_SERVER_URL` from its
own environment (not something the user ever types — see `.env.example`).
On success it mints its own opaque session id, stores
`{embyToken, userId, userName}` server-side (in-memory, like party state),
and hands the browser only that session id, as an `httpOnly` cookie
(`wp_session`). The browser never sees or holds Emby's real access token.
- Every other request — catalog search, item details, images, HLS
streaming, party actions — goes to **our own origin only**
(`/api/...`), authenticated by that same cookie. `server/src/embyProxy.js`
is what actually forwards these to Emby server-side and streams the
response back, attaching the session's real Emby token itself.
- The `/api/catalog/stream/:itemId/master.m3u8` and `.../raw` routes proxy
HLS specifically: the master playlist is fetched from Emby server-side,
and every URI line in it is rewritten to point back through `.../raw`
(whether Emby wrote it as relative or absolute) — otherwise hls.js would
follow the playlist's own URLs straight to Emby the moment it tried to
fetch a segment, defeating the whole point. See `rewritePlaylist` in
`embyProxy.js`.
- Images (`<img src>`) and video (`hls.js`/`<video>`) still can't carry a
bearer header, but that's fine now: they're same-origin requests to our
own `/api/...` paths, so the session cookie rides along automatically —
no token in any URL anywhere, unlike the first version's `?api_key=...`.
This also means the client no longer needs to ask for a server URL at all —
one less field, one less way to get it wrong. `EMBY_SERVER_URL` lives only
in the backend's environment.
### Control model: host drives, everyone else follows
Now that we own the player, the classic watch-party model (Teleparty,
Amazon Watch Party, ...) fits better than the old plugin's "everyone keeps
their own controls, shared reference position" — that design was specifically
a concession to nudging *pre-existing, independently-running* Emby app
sessions we didn't control. Here, there's only one player implementation,
so:
- The party creator is the host. Their player has normal controls.
- Everyone else's `<video>` has controls hidden; a small notice says the
host is in charge.
- Host → server → everyone else, over Socket.IO
(`server/src/socketHandler.js`):
- Event-driven: every `play`/`pause`/`seeked` on the host's video sends
`{type:"playstate", isPaused, positionTicks}` immediately.
- Heartbeat: also sent every 3s regardless, so latecomers/drift get
corrected even without a discrete host action.
- Followers apply pause/unpause immediately, and only seek if drift
exceeds 1.5s (`DRIFT_TOLERANCE_SECONDS` in `client/js/views/player.js`)
— avoids fighting small timing noise with constant re-seeks.
- If the host leaves, the server promotes another member (same
fallback logic the old plugin used for its "reference" member).
### Party lifecycle
In-memory only on the backend (`server/src/partyManager.js`) — a restart
clears every party, which is fine: a party is meant to last one watch
session, not persist for weeks. `removeIdleParties` sweeps anything with no
activity for 4 hours.
## Realtime transport: Socket.IO, and two real memory leaks
The realtime layer was originally a raw WebSocket (the `ws` package). Two
things prompted a rework — a requirement (use Socket.IO instead) and a
specific worry (memory leaks) — and auditing for the second turned up two
real, concrete leaks, independent of which transport library is used:
1. **Server-side: dead connections never got cleaned up.** A raw `ws`
server has no idea a client is gone unless it receives a proper close
frame. A laptop put to sleep, a network drop, a killed tab that never
gets to send one — none of those fire `ws`'s `'close'` event, so the
`PartyMember`'s socket reference (and everything it closes over) would
sit in memory forever, and the party would never get cleaned up by
anything (`removeIdleParties` only looks at `lastActivityUtc`, which a
phantom connection doesn't affect). `ws`'s own docs recommend a manual
ping/pong heartbeat to detect this - Socket.IO has one built in
(`pingInterval`/`pingTimeout`), which is a real, not just cosmetic,
reason to prefer it here, not only the requested API ergonomics.
2. **Client-side: leaving the player any way other than its own "Quitter"
button leaked everything.** `router.js` only ever did
`currentRoot.innerHTML = ''` between routes — that destroys DOM nodes,
but nothing told the outgoing view's *JavaScript* to stop. Clicking a
sidebar link or the browser's back button while watching left the
player's 3-second heartbeat `setInterval`, its realtime connection, and
its `hls.js` instance all running against a detached `<video>` element
indefinitely - for as long as the tab stayed open. Fixed by having
`route()` handlers optionally return a cleanup function; `router.js`
calls whatever the previous view returned right before rendering the
next one, regardless of how navigation happened. `player.js` returns one
that clears the heartbeat, destroys `hls`, disconnects the socket, and
removes the one `document`-level click listener it adds (named, not
inline-anonymous, specifically so it's removable at all - an anonymous
listener added fresh on every visit would itself have been a slow leak
even with everything else fixed).
Both are now covered by the same class of test: drive the router through
several "join a party, then navigate away without clicking Quitter" cycles
and check nothing accumulates. Verified directly (see session history) by
instrumenting `document.addEventListener`/`removeEventListener` and
`setInterval`/`clearInterval` around three such cycles against a really-running
local server (Socket.IO connection included, not mocked) - adds and removes
matched exactly (3/3 listeners, 2/2 intervals across two cycles), nothing
left pending. The server-side heartbeat fix is Socket.IO's own
well-established mechanism, not custom code, so it wasn't separately
instrumented the same way.
`server/src/socketHandler.js` mirrors the old `wsHandler.js` protocol
closely (see its own top comment for the exact event list) - the one naming
difference is the error event, sent as `"errorMessage"` rather than
`"error"`, since Socket.IO reserves the latter for its own internal error
handling.
## Live lobby, host handoff, no join codes
Three related changes, all in service of "the home page should reflect
reality without a refresh":
- **The home page's party list is a live subscription, not a fetch.**
`client/js/party.js`'s `connectLobby` joins a `"lobby"` Socket.IO room
(`subscribeLobby` → `server/src/socketHandler.js`) and gets a fresh
snapshot on subscribe, then again every time `broadcastLobby(io)` runs -
which is now wired into every place party membership changes: creating a
party, joining one (both in `index.js`'s REST handlers), and leaving
(`applyLeave`, used by both the socket `leave`/`disconnect` handlers and
the REST leave endpoint, so this - and the host-handoff notification below
- only has to be right in one place). `home.js` returns this subscription
as part of its router cleanup function, same as the player does for its
own socket - an open lobby subscription left running after navigating away
would have been exactly the same class of leak just fixed above.
- **Host and elapsed time are shown per party**, per the request. Host name
and member count come from the same snapshot. Elapsed time doesn't: it
ticks client-side from a `createdUtc` timestamp the server includes once
(`home.js`'s `setInterval` re-renders every `.elapsed` label every
second) - pushing a recomputed duration from the server every second for
every viewer would be pure waste when the client can derive it from one
number. Verified live (see session history): faking a `parties` push with
a `createdUtc` two minutes in the past and waiting rendered "2:06", not a
static value.
- **No join codes.** `PartyManager` no longer generates or tracks one - see
its class comment. The home page's live list is now the only way to find
a party; a code that has to be copied and typed out of band doesn't add
anything on top of a list that's already always current. `POST
/api/parties/:partyId/join` replaces the old `:joinCode` route.
- **Host handoff is live, not just correct in the data model.**
`PartyManager.leave` already promoted another member to host when the
host left and destroyed the party once empty - that part predates this
change. What was missing: a player already open as a follower had no way
to find out it became host mid-session. `applyLeave` now emits a personal
`"hostChanged"` (to that one socket, not the room) when this happens;
`player.js`'s `becomeHost()` - the same function `init()` calls when you
already are the host - unlocks the controls live, no reload needed.
`becomeHost` is written to tolerate being called more than once safely:
`addEventListener` with the same function reference is a no-op if already
attached, and the heartbeat `setInterval` is explicitly guarded
(`if (!heartbeat)`) since `setInterval` has no such built-in idempotence -
calling it twice would leak the first interval's id, the exact class of
bug the rest of this pass was about fixing, not reintroducing.
One incidental bug found and fixed while touching this code: the home
page's "Rejoindre" button on a live party card used to `navigate()` straight
to the player **without ever calling the join endpoint first** - it worked
well enough to load the video and read basic party info (`GET
/api/parties/:id` never checked membership), but the realtime `"join"`
would be rejected server-side ("Tu n'es pas membre de cette party.") since
`socketHandler.js`'s join handler does check membership - meaning sync
silently never worked for anyone who joined this way. Fixed by awaiting
`joinParty()` before navigating, same as the library grid's tiles already
did correctly.
## Player: no page scroll
`.playerWrap` was `width:100%; height:100vh` sitting inside `.mainContent`,
which has its own padding - the two stacked (100vh *plus* padding) made the
whole shell taller than the viewport, so the page grew a scrollbar even
though the video itself was already full-height. Changed to
`position:fixed; inset:0` so it's sized against the real viewport
regardless of any ancestor's padding, and (like most players' theater mode)
covers the sidebar while watching - confirmed via computed style
(`position: fixed`, all four insets `0px`) rather than just asserted.
## Presence overlay and "request pause"
Requested as a pair: a semi-transparent overlay showing who's in the party,
and a way for a non-host to ask the host to pause without a chat feature
(deliberately out of scope — see Non-goals). Revised once after the first
pass, based on concrete feedback: the overlay moved from bottom-left to the
top of the player, and the "someone requested a pause" indicator became a
specific orange border + pause-icon badge rather than a generic pulse
animation.
- **Layout**: the player's top gradient bar is now `.topOverlay`, a column
of two stacked rows — `.actionBar` (title/members-text/next/settings/leave,
what used to be the whole bar) and, directly below it, `#presenceOverlay`.
Stacking them in normal flow inside one column means the avatar row always
lands right after the action bar regardless of how tall that bar renders
(it wraps to two lines on narrow screens) — deliberately not a second
absolutely-positioned block at a guessed pixel offset, which would either
overlap the action bar when it wraps or leave an inconsistent gap when it
doesn't.
- **Presence**: `client/js/views/player.js`'s `renderMembers()` — already
responsible for the text member list — now also rebuilds
`#presenceOverlay`, one circle per member. Each circle shows the first two
characters of `userName`, uppercased (`initialsFor`), on a background color
picked deterministically from a small fixed palette by hashing the name
(`colorForName`) — same person, same color, every render, with no need for
the server to assign or track one. `opacity: .75` on `.avatar` is the
"semi transparence" itself, applied to the whole circle (initials included)
rather than just its background, since a chat-bubble-style translucent chip
reads clearly enough at that opacity and avoids converting the hash color
to an rgba string just to keep text fully opaque.
- **Which circle is "yours"** is decided client-side by comparing
`member.userName === auth.userName` (the same `auth` singleton
`embyApi.js` already populates at login/session-restore) rather than
by `userId` — consistent with the rest of the client already treating
`userId` as a server/protocol-internal detail (see `hostChanged`'s empty
payload) and not something to key UI logic on directly.
- **Request pause**: your own circle — only when you're *not* the host, the
host already has direct controls — gets `.avatarClickable` and a click
handler that calls the party connection's `requestPause()`
(`client/js/party.js` → `"requestPause"` socket event). The server
(`server/src/socketHandler.js`) broadcasts `"pauseRequested"
{userId, userName}` to **everyone** in the party, not just the host, so
the whole group sees why playback is about to pause, and throttles it
server-side per member (5s, `member.lastPauseRequestUtc`) independently of
the client's own identical cooldown (`PAUSE_REQUEST_COOLDOWN_MS` in
`player.js`) — the server-side one is the real guard (anyone poking the
socket directly bypasses a client-only check); the client one just avoids
a pointless click doing nothing.
- Receiving `"pauseRequested"` shows a short banner (`#pauseRequestBanner`,
"`<name>` demande une pause") and flags the matching person's circle
(`.avatarPauseRequested`: an orange `#ff9800` border plus a small pause-icon
badge in its corner — `.avatarPauseBadge`, a plain inline two-bar SVG
rather than a Unicode glyph, since pause/play characters default to emoji
presentation on several platforms and look muddy at 10px) — both matched
by the event's `userId` against each circle's `data-user-id`, both clearing
automatically after `PAUSE_REQUEST_HIGHLIGHT_MS` (4s), for every member,
including the requester and the host — deliberately not host-only, so a
request landing while the host isn't looking at the tab is still visible
to everyone else, and the requester gets confirmation their own click
registered. This replaced an earlier plain box-shadow pulse animation and
a separate immediate-local-feedback ring on just the requester's own
circle (`.avatarSent`) — collapsed into one unambiguous visual language
driven purely by the server broadcast (which the requester also receives,
so there's no need for a separate client-only echo).
- `.followerNotice` picked up a `margin: 0` it was missing: it's a `<p>`,
and for an absolutely positioned element `bottom` places the *margin*
edge, so its default ~1em user-agent margin was quietly rendering it 13px
higher than its declared `bottom: 24px` ever suggested. Harmless on its
own until `.pauseRequestBanner` needed to sit a specific gap above it —
found by measuring both rects and seeing the gap read as 2px instead of
the ~15px the two `bottom` values alone implied.
- Nothing about this feature actually pauses anything automatically — "request"
is literal, the host still has to act on it. Adding an actual forced-pause
would be a real design decision (can any member override the host's
playback state? that cuts against "host drives, everyone follows") and
wasn't asked for.
Verified end to end with mocked `members`/`pauseRequested` Socket.IO events
against the real running local server (see session history): the presence
row renders directly under the action bar; initials/colors render correctly
for a two-member party; the host's circle never gets `.avatarClickable`
(neither when viewed by themselves nor by others); a `pauseRequested` event
gives the right circle `rgb(255, 152, 0)` as its computed border color and
`display: flex` on its badge (confirmed via computed styles, not just class
names) while leaving every other circle untouched; rapid repeat clicks on
your own circle within the cooldown collapse to exactly one `requestPause`
emission (three synchronous clicks in one script → the emitted-event count
increased by exactly one — an earlier two-separate-clicks check had looked
like a cooldown failure at first, but that was real wall-clock time between
two separate tool calls exceeding the 5s window, not a code bug); and the
banner/`.followerNotice` gap measured 15px after the margin fix, versus 2px
before it. The host-only controls (`becomeHost`, settings menu,
next-episode button) were re-checked after this change and still work.
## Player track selection: forcing our pick onto Emby's own negotiation
First real-world bug report once someone actually watched something end to
end: picking a language or subtitle track in the settings menu had no
effect on playback. Streaming itself working at all (this is the first
confirmation of that - see below) narrowed it down: `PlaybackInfo` and its
`TranscodingUrl` are reachable and produce a playable HLS stream, but the
specific track requested wasn't the one that came out the other end.
Likely cause, reasoned from how Emby/Jellyfin's own web client actually
negotiates this (not verified against this exact server - no way to, from
here): that client sends `AudioStreamIndex`/`SubtitleStreamIndex` as part
of a `POST /Items/{id}/PlaybackInfo` body alongside a `DeviceProfile`, not
as bare query-string params on a `GET` - which is all this app has ever
sent. Emby's `TranscodingUrl` reflects whatever `PlaybackInfo` actually
negotiated, so if the plain `GET` doesn't apply those params, the returned
URL just encodes Emby's own default track choice regardless of what we
asked for - and the old code used that URL as-is. Separately, even a
correctly-selected subtitle track has a second way to silently not show up:
Emby can deliver text subtitles either burned into the video
(`SubtitleMethod=Encode`) or as their own selectable HLS rendition
(`SubtitleMethod=Hls`) - this player's hls.js integration never wires up
`subtitleTrack`/`subtitleTracks` to pick a rendition, so a `Hls`-method
subtitle would be present in the manifest but never actually rendered.
Fixed defensively rather than by trying to exactly replicate Emby's own
client (`server/src/index.js`'s `/master.m3u8` route + its new
`applyPlaybackOverrides` helper): whichever URL we end up with - Emby's own
`TranscodingUrl`, or the hand-built fallback (which, before this fix,
dropped the audio/subtitle/quality query params entirely - a second,
independent way the same symptom could happen if a title ever took that
path) - our own `AudioStreamIndex`/`SubtitleStreamIndex`/
`MaxStreamingBitrate` are forced onto its query string afterward,
overwriting whatever Emby put there. Picking a subtitle also forces
`SubtitleMethod=Encode`; picking "Aucun" removes both
`SubtitleStreamIndex` and `SubtitleMethod` rather than just not adding one,
so it also cancels a subtitle Emby's own negotiation had defaulted to. This
means the negotiation call itself no longer has to be trusted at all - it
only needs to return *a* playable `MediaSource`, since every parameter this
app's UI actually exposes gets forced afterward regardless.
Verified as far as possible without real Emby access: `applyPlaybackOverrides`
is pure `URLSearchParams` manipulation, unit-tested in isolation (adding a
subtitle Emby didn't pick, removing one Emby had defaulted to, re-sending an
unchanged selection across an audio-only change, quality override, and the
no-selection-yet initial-load case) - all five cases produce the expected
final query string. Separately confirmed client-side, by mocking `Hls` to
capture what URL `loadSource` was actually called with: the initial load
already sends the default-selected track's index, and each dropdown change
re-sends a full reload with exactly the accumulated params expected
(switching audio → `AudioStreamIndex` changes; adding a subtitle → both
params present; back to "Aucun" → `SubtitleStreamIndex` dropped again).
What's **not** verified is the actual Emby round trip - whether
`applyPlaybackOverrides`'s forced params, once they reach the real server,
produce a transcode that really carries the requested track. That needs a
real retest.
## Assumptions to verify
**Confirmed against the real server** (not a guess — actually exercised,
see session history): `POST /Users/AuthenticateByName` really is reachable
from Node's server-side `fetch` with no CORS issue at all (server-to-server
has no CORS concept), returns a real 401 for bad credentials, and the whole
login round-trip (browser → our backend → real Emby → real rejection → our
error message) renders correctly end to end. The `--theme-primary-color-*`
etc. custom properties `client/css/app.css` is built from were read directly
off this exact server's live dark theme, not guessed.
**Login and basic usage are now confirmed for real** — updated once actual
bug reports started coming from real usage rather than mocked testing
(overlay/pause-request feedback, the series-detail request, and the
track-selection bug above, which is only reachable once a stream is already
playing). That means the following are confirmed working end to end against
this real server, not just reasoned about, even though none were checked in
isolation: response field names (`AccessToken`, `User.Id`/`Name`, item
`Id`/`Name`/`Overview`/`ProductionYear`/`Type`, `Views`' `CollectionType`,
`Shows/{id}/Seasons`/`Episodes`, `Items/Latest`'s plain-array shape,
`PlaybackInfo`'s `MediaStreams`), the `X-Emby-Authorization`/`X-Emby-Token`
header format, `rewritePlaylist` against a real Emby-generated playlist, and
`PlaybackInfo` → `TranscodingUrl` → HLS producing a real watchable stream.
None of this was independently exercised - it's inferred from "the features
built on top of it work in practice, and nobody's reported the classes of
failure that would follow from any of it being wrong" - worth keeping in
mind if something in this area breaks in a way that doesn't fit whatever
specific bug is being chased at the time.
**Genuinely not yet 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).
- **Player track selection** (`AudioStreamIndex`/`SubtitleStreamIndex`
actually taking effect) — see the section just above; a fix is in place,
reasoned from how Emby's API is known to work, but not yet re-tested for
real.
- Whether `TranscodingUrl` is reliably present for every source type (a
direct-play-eligible source might not transcode at all, which `hls.js` on
the client wouldn't handle — see Known limitations), and whether it's
always HLS.
- **The Emby web client deep-link shape**
(`/web/index.html#!/item?id=X&serverId=Y`) used for "Voir sur Emby" —
matches the long-standing convention, not confirmed against this exact
build's routing (nobody's reported clicking it yet either way).
## UI: Emby-like shell, per-type browsing, episode queue
Reworked to look and navigate more like Emby itself, and to treat each
collection type (movies / TV / music) as its own experience rather than one
generic grid:
- **`client/js/shell.js`** renders the persistent chrome once per login: a
sidebar (collections from `GET /api/catalog/libraries`, one icon per
`collectionType`) + a `#mainContent` element the router (`router.js`)
swaps on every navigation. `router.js` was made idempotent
(`start(root)` can be called again safely) specifically so login → shell
boot can happen more than once in a page's lifetime without piling up
duplicate `hashchange` listeners — the same class of bug the earlier Emby
plugin work ran into with its page lifecycle, worth remembering.
- **`views/home.js`** (route `/home`): a horizontal carousel of every active
party server-wide (`GET /api/parties`, deliberately not filtered to "your"
parties — the point is letting people discover and join one without a
code passed out of band) plus a join-by-code fallback, then one more
carousel per library of its recently-added items
(`GET /api/catalog/libraries/:id/recent`, Emby's own
`/Users/{id}/Items/Latest`) — skipping music (see below).
- **`views/library.js`** (route `/library/:id`): dispatches purely on the
library's `collectionType`. `movies`/`tvshows`/anything else gets a tile
grid (`GET /api/catalog/libraries/:id/items`, `Recursive=false` — Movies
as movies, TV as Series tiles); `music` gets an honest "not built yet"
message instead of a half-working page (see Non-goals).
- **Tiles** (`client/js/tiles.js`, shared by the home carousels and the
library grid so they can't drift apart): poster + name, hover-revealed
overlay with "Lancer une watch party" (only for directly-playable types —
a Series tile gets "Voir les épisodes" instead, linking to `/series/:id`)
and "Voir sur Emby" (opens Emby's own web client via a `embyUrl` the
backend builds from `EMBY_SERVER_URL` + the session's `serverId`).
`:hover`/`:focus-within` reveal it on mouse/keyboard; `@media (hover: none)`
keeps it always visible on touch devices, which have no hover state at all.
- **`views/series.js`** (route `/series/:id`): a detail header — poster,
title + year, overview, a "Voir sur Emby" link — above season tabs
(`GET /api/catalog/series/:id/seasons`, Emby's `/Shows/{id}/Seasons`) and
an episode list per season (`GET /api/catalog/series/:id/episodes`, Emby's
`/Shows/{id}/Episodes?SeasonId=...`), each episode startable as its own
party. The header is just `toItemDto`'s existing fields
(`GET /api/catalog/item/:id`, the same DTO shape as everything else) —
the season/episode browsing itself predates this pass and was already
wired end to end (library grid → Series tile → this route → "Lancer une
watch party" per episode); what this pass added was making the page
actually look like a detail page instead of a bare `<h1>` above the tabs.
`.linklike`'s CSS selector was widened from `button.linklike` to
`.linklike` so the new "Voir sur Emby" `<a>` picks it up too — there's no
global `a` reset in `app.css`, so an unclassed anchor would otherwise
fall back to the browser's default blue/underline against this dark theme.
### Episode queue / "next episode"
Starting a party from an episode (`POST /api/parties`) now has the backend
fetch the rest of that series (`/Shows/{seriesId}/Episodes`, no season
filter) and store everything from the clicked episode onward as the party's
`queue` (`server/src/partyManager.js`). The host gets an "Épisode suivant"
button once `hasNext` is true; clicking it (or the video's `ended` event
firing) sends a `"next"` Socket.IO event
(`server/src/socketHandler.js`), host-only, which advances `queueIndex`,
resets every member's known position, and broadcasts `{type:"itemChanged",
itemId, itemName, ...}` — every client (including the host) reloads the
player against the new `itemId` on that message. A movie or a
standalone-started episode just gets a one-item queue, so this is a strict
superset of the old single-item behavior, not a separate code path.
### Player: language, subtitles, quality
`GET /api/catalog/item/:id/playback-options` reads `PlaybackInfo`'s
`MediaSources[0].MediaStreams` and returns the Audio/Subtitle entries as
`{index, label}` pairs for two `<select>`s in the player's host-only
controls. Picking a value reloads the stream
(`/api/catalog/stream/:id/master.m3u8?AudioStreamIndex=&SubtitleStreamIndex=&MaxStreamingBitrate=`)
at the same position (`hls.js` destroyed and rebuilt, `currentTime`/paused
state preserved across the reload) rather than restarting from zero.
"Quality" is a fixed set of `MaxStreamingBitrate` presets (Auto/1080p/720p/
480p) forwarded to `PlaybackInfo` — the same simplification Emby's/
Jellyfin's own web clients use; it's a transcode ceiling, not true
client-side adaptive bitrate switching. Subtitles picked this way get
burned into the transcoded video (Emby's doing, via `SubtitleStreamIndex`)
rather than rendered as a toggleable `<track>` — simpler to implement
correctly than sidecar WebVTT, at the cost of a re-transcode to turn them
on/off.
### Confirmed vs. not, for all of the above
Driving an actual browser against this (see session history) confirms the
**rendering and interaction logic**: sidebar highlighting, home carousels
(parties + one row per non-music library), library grid dispatch (including
a `tvshows` library's Series tiles linking to `/series/:id` both via the
poster and the "Voir les épisodes" button), and the series page's detail
header + season-tab switching + episode list + starting a party from an
episode all render and navigate correctly. That test mocked every
`/api/...` response (canned JSON), since
exercising it for real needs a logged-in session — so what's **not**
confirmed yet is the shape of the real Emby responses this now depends on:
`Views` items actually carrying a `CollectionType` field the way assumed,
`/Shows/{id}/Seasons` and `/Shows/{id}/Episodes`, `Items/Latest`'s
plain-array response shape (handled defensively — see `index.js` — but not
seen for real), and `PlaybackInfo`'s `MediaStreams` shape for the track
pickers. The queue/"next episode" WebSocket flow and the player's
track/quality reload are new protocol surface with **no test coverage at
all yet** (no way to exercise real HLS playback without a live session) —
the first things to check once a real party actually plays something.
## Non-goals (updated)
**Music** was requested (dedicated audio player, artist/album browsing, a
shared/collaborative playlist party members can add to together) but is
deliberately not in this pass: it's a genuinely different subsystem — a
different player paradigm, full library-depth browsing down to the track,
and real-time collaborative queue state that doesn't fit the existing
"host drives, everyone follows" model at all (multiple people adding to one
shared list is a different kind of shared state than one host's playback
position). The sidebar and library routing already treat `music` as a first
-class `collectionType` specifically so slotting in a real implementation
later doesn't need another structural pass — see `library.js`'s early
return. Right now it just shows an honest placeholder.
## Known limitations
- 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
the next heartbeat corrects.
- No persistence: server restart or long idle time ends every party.