All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
server/src/index.js: GET /api/catalog/libraries now filters Emby's /Views
response to VISIBLE_COLLECTION_TYPES (movies/tvshows/music/musicvideos)
before returning it. Emby's /Views can return all sorts of things this app
has no UI for at all - boxset collections, playlists, live TV, photos,
books, a mixed-content folder with no CollectionType - none of which
should ever have shown up in the sidebar. Filtering once at the source
means every consumer (the sidebar, home.js's per-library carousels, direct
navigation to /library/:id) is restricted the same way without repeating
the check in each place; a stale link to a now-hidden library's id just
falls into library.js's existing "Bibliothèque introuvable" state, since
it looks the id up in this same filtered list.
Found while making musicvideos a real visible category: its items are
Emby's own 'MusicVideo' type (distinct from the generic 'Video'), which
WATCH_PARTY_TYPES didn't include - every tile in a music-videos library
would have rendered with no "Lancer une watch party" button at all, only
"Voir sur Emby". Added it.
Also fixed two stale doc/comment references while touching this code:
the sidebar hasn't shown per-type icons since the earlier "no emoji"
request (plain text links only), and views/home.js's party carousel has
used the live lobby Socket.IO subscription instead of a GET /api/parties
fetch since that endpoint was removed - both docs/architecture.md and an
inline comment still described the old behavior.
Verified: a plain filter/map unit test confirms exactly
movies/tvshows/music/musicvideos survive against a mock /Views response
seeded with one of every excluded type (photos/livetv/boxsets/playlists/
books/homevideos/mixed/no-type-at-all); WATCH_PARTY_TYPES.has('MusicVideo')
checked directly; and mounting shell.js + library.js against a mocked
already-filtered libraries response renders exactly the 4 expected sidebar
links and a working "Lancer une watch party" button on a MusicVideo tile.
docs/architecture.md has the full writeup.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
598 lines
36 KiB
Markdown
598 lines
36 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 (one plain-text link per library from `GET /api/catalog/libraries`
|
|
— no icons, no emoji prefix, per an explicit earlier request) + a
|
|
`#mainContent` element the router (`router.js`) swaps on every navigation.
|
|
`GET /api/catalog/libraries` itself only ever returns libraries whose
|
|
`CollectionType` is `movies`/`tvshows`/`music`/`musicvideos`
|
|
(`VISIBLE_COLLECTION_TYPES` in `server/src/index.js`) — Emby's `/Views` can
|
|
return all sorts of things this app has no UI for at all (boxset
|
|
collections, playlists, live TV, photos, books, a mixed-content folder
|
|
with no `CollectionType`), and filtering once at the source means the
|
|
sidebar, the home page's per-library carousels, and direct navigation to
|
|
`/library/:id` are all restricted the same way without repeating the
|
|
check in each place — a stale link to a since-hidden library's id just
|
|
renders `library.js`'s existing "Bibliothèque introuvable" state, since
|
|
it looks the id up in this same filtered list. `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 (the live lobby subscription, not a fetch — see "Live
|
|
lobby, host handoff, no join codes" above) plus 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`/`musicvideos` all get a tile
|
|
grid (`GET /api/catalog/libraries/:id/items`, `Recursive=false` — Movies
|
|
and music videos as themselves, 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 —
|
|
`WATCH_PARTY_TYPES` in `server/src/index.js`, which had to grow a
|
|
`MusicVideo` entry once `musicvideos` became a visible library type,
|
|
otherwise every tile in it would render with no way to start a party at
|
|
all; 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.
|