From 05c4611403ab047185ca065793fcf2fab95a1cc4 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 15 Sep 2026 22:48:44 +0200 Subject: [PATCH 1/4] Fix real data loss: two catalog queries were silently truncating at 300 Reported right after the previous PR merged: "the catalogs are broken, not all the data is there". Confirmed directly against the real Emby server, not guessed - this app's own "Musique" library has 1229 distinct artists and its "Films" library has 421 movies. getArtists gained a 300-item Limit in that same previous PR (meant defensively, alongside the render-side fix below); getLibraryItems already had one, apparently written before this - both were silently dropping the tail of the list with no error and no visible sign anything was missing, which is exactly what "some of the data is gone" looks like from the outside. Removed both. Confirmed empirically that omitting Limit entirely makes Emby return everything (TotalRecordCount matched Items.length exactly for both the 421-item and 1229-item cases, no server-side default cap kicking in) - there was never a reason for either app-side number beyond guarding the render path, and that guard already exists elsewhere: tiles.ts's renderTilesInChunks (the previous PR's fix for "the front freezes while it loads") inserts any size of list in small batches regardless of how large it is. While in there: renderTilesInChunks itself was scheduling its next chunk with requestAnimationFrame, which browsers deliberately pause for a backgrounded/hidden tab - completely ordinary to hit in practice (switching tabs while a 1229-item list is still loading, which now takes meaningfully longer than the 300-item case this was written and tested against) and would have stalled the render indefinitely, presenting as the exact same "data missing" symptom for a different reason. Switched to setTimeout(fn, 0), which keeps running (only throttled, never paused) in a background tab. Verified: full typecheck/test/build on both projects (122/122 server tests). The two real counts above (421, 1229) and the "no Limit means Emby returns everything" behavior were both confirmed with live, read-only requests against the real server - not assumed. Not verified: an actual browser render of either list end to end (no Emby login reachable from this sandbox), though the render mechanism itself was already verified separately in the previous PR and is unchanged here beyond the scheduling primitive. Co-Authored-By: Claude Sonnet 5 --- client/src/tiles.ts | 31 +++++++++++++++-------- server/src/services/CatalogService.ts | 36 +++++++++++++++++++-------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/client/src/tiles.ts b/client/src/tiles.ts index 878cc80..4d85f70 100644 --- a/client/src/tiles.ts +++ b/client/src/tiles.ts @@ -96,15 +96,26 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string) } /** - * Renders `items` into `container` a chunk at a time, across animation - * frames, instead of one `innerHTML` write for the whole list - a library - * grid can hand back up to 300 tiles at once (CatalogService.getLibraryItems/ - * getArtists), and building + inserting/laying out ~300 posters in a - * single synchronous task is exactly what a "the page freezes while it - * loads" report looks like on a slower device. Splitting the same HTML - * across a few chunks keeps every individual task well under a frame's - * budget, so the tab stays responsive (and the loading spinner keeps - * animating) while it works through the rest. + * Renders `items` into `container` a chunk at a time instead of one + * `innerHTML` write for the whole list - a library grid can legitimately + * hand back over a thousand tiles now that CatalogService.getLibraryItems/ + * getArtists no longer cap themselves (confirmed against the real server: + * one library alone has 1229 artists - see their own comments), and + * building + inserting/laying out that many in a single synchronous task + * is exactly what a "the page freezes while it loads" report looks like + * on a slower device. Splitting the same HTML across a few chunks keeps + * every individual task well under a frame's budget, so the tab stays + * responsive (and the loading spinner keeps animating) while it works + * through the rest. + * + * Scheduled with `setTimeout(fn, 0)`, not `requestAnimationFrame` - rAF + * callbacks are deliberately paused by the browser for a backgrounded/ + * hidden tab, which is a completely ordinary thing to do while a large + * library is still loading (switching tabs, minimizing the window) and + * would otherwise stall the render indefinitely, looking exactly like + * "some of the data is missing" rather than "still loading". setTimeout + * keeps running in a background tab (only throttled, never paused), so + * this always finishes on its own. * * Uses `insertAdjacentHTML`, not repeated `innerHTML +=` (which would * re-parse and re-render everything already inserted on every chunk). @@ -120,7 +131,7 @@ export function renderTilesInChunks(container: HTMLElement, items: ItemDto[], ch if (!chunk.length) return; container.insertAdjacentHTML('beforeend', chunk.map(tileHtml).join('')); i += chunkSize; - if (i < items.length) requestAnimationFrame(renderNextChunk); + if (i < items.length) setTimeout(renderNextChunk, 0); } renderNextChunk(); } diff --git a/server/src/services/CatalogService.ts b/server/src/services/CatalogService.ts index c1691f4..9bb13fe 100644 --- a/server/src/services/CatalogService.ts +++ b/server/src/services/CatalogService.ts @@ -199,11 +199,13 @@ class CatalogServiceClass { // structure, same reasoning as Shows -> Seasons -> Episodes above. async getArtists(session: EmbySession, libraryId: string): Promise { - // Limit added for the same reason as getLibraryItems's own - unbounded - // on a large music library, this used to hand back every artist at - // once for the client to render in a single pass (see tiles.ts's - // renderTilesInChunks for the render-side half of that fix). - const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName', Limit: '300' }); + // No Limit, deliberately - see getLibraryItems's own comment below for + // why one very nearly shipped here and got pulled: confirmed directly + // against the real server that this library alone has 1229 artists, + // so a 300 cap would have silently dropped 75% of them, not just + // trimmed an edge case. Rendering that many is tiles.ts's + // renderTilesInChunks's job, not this query's. + const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName' }); const data = await this.fetchJson(`/Artists?${params}`, session, 'Impossible de récupérer les artistes.'); return (data.Items || []).map((a) => this.toItemDto(a, session.serverId)); } @@ -238,17 +240,29 @@ class CatalogServiceClass { // renders tileHtml's poster/name/year (views/library, tiles.ts), which // never reads it. Traced here after "the catalog API is too slow, the // front freezes while it loads" - Emby happily serializes a full - // synopsis for all 300 items, and the client happily JSON.parses all - // of it, entirely to be thrown away unread. Contrast getSeriesEpisodes/ - // getPlaylistItemsRaw, which keep Overview because their own views - // (Series/Playlist) actually display it. + // synopsis for every item anyway, and the client happily JSON.parses + // all of it, entirely to be thrown away unread. Contrast + // getSeriesEpisodes/getPlaylistItemsRaw, which keep Overview because + // their own views (Series/Playlist) actually display it. + // + // No Limit - there used to be one here (300), presumably meant as the + // same defensive cap getArtists briefly grew (see its own comment). + // Confirmed directly against the real server that this app's own + // "Films" library alone has 421 items - past that cap, so it was + // quietly serving an incomplete grid with no error and no hint + // anything was missing, reported as "the catalog is broken, not all + // the data is there". Emby returns everything when Limit is omitted + // (confirmed: TotalRecordCount matched Items.length exactly with no + // Limit param, for both this library and the 1229-artist one above) - + // rendering that many tiles without freezing the tab is + // renderTilesInChunks's job (tiles.ts), not something this query + // should be truncating data to work around. const params = new URLSearchParams({ ParentId: libraryId, Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode) SortBy: 'SortName', Fields: 'ProductionYear,AlbumId', - ImageTypeLimit: '1', - Limit: '300' + ImageTypeLimit: '1' }); const data = await this.fetchJson(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette bibliothèque.'); return (data.Items || []).map((i) => this.toItemDto(i, session.serverId)); From 26d32632c76fd1906ee619aff4cba6d74309894b Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 15 Sep 2026 22:50:08 +0200 Subject: [PATCH 2/4] Hide the cursor over the video once the bottom bar auto-hides Asked for directly: the auto-hide behavior itself (bottom bar collapses to just its seek line after a stretch of no activity) already shipped earlier - a mouse cursor left sitting over the video afterward is the same kind of clutter that was the point of hiding in the first place. .playerWrap now gets a .cursorHidden class alongside #bottomBar's own .minimal, toggled at the exact same two call sites (scheduleControlsHide/showControls) so the two states can never drift out of sync with each other. cursor: none on that class does the rest - no new listener needed, since playerWrap's own pointermove/pointerdown already call showControls() on any mouse activity, which is exactly when the cursor should reappear too. Verified: typecheck + build (client). Confirmed the compiled bundle carries both the classList calls and the CSS rule. Not verified in an actual running player - no Emby login reachable from this sandbox to open a real video party and let the auto-hide timer elapse. Co-Authored-By: Claude Sonnet 5 --- client/src/views/player/Player.css | 9 +++++++++ client/src/views/player/Player.ts | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/client/src/views/player/Player.css b/client/src/views/player/Player.css index ab8be56..421805d 100644 --- a/client/src/views/player/Player.css +++ b/client/src/views/player/Player.css @@ -16,6 +16,15 @@ object-fit: contain; } +/* Toggled alongside .bottomBar.minimal (see Player.ts's scheduleControlsHide/ + showControls) - a mouse cursor left sitting over the video is the same + kind of clutter the bar itself was, once there's nothing left under it + to interact with. Any pointermove/pointerdown anywhere in .playerWrap + already calls showControls(), so the cursor reappears the instant the + mouse moves again - same convention as any other video player's + auto-hiding controls. */ +.playerWrap.cursorHidden { cursor: none; } + /* Two stacked rows, both part of the same top gradient overlay: the action bar (title/members/next/settings/leave) and, below it, the presence avatars - see .presenceOverlay in styles/presence.css. Stacking them in diff --git a/client/src/views/player/Player.ts b/client/src/views/player/Player.ts index 269e62d..d5b80f8 100644 --- a/client/src/views/player/Player.ts +++ b/client/src/views/player/Player.ts @@ -530,7 +530,11 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli * sitting on the bar itself, and re-arms on every discrete playback * event too (onDriverStateChanged above), not just raw pointer * activity - resuming from pause has to restart the countdown, not - * leave the bar stuck open forever. + * leave the bar stuck open forever. .playerWrap also gets + * .cursorHidden alongside .minimal (see Player.css) - asked for + * directly too: a mouse cursor left sitting over the video is exactly + * the same kind of clutter the bar itself was, once there's nothing + * left under it to interact with. */ function clearControlsHideTimer() { if (controlsHideTimer) { @@ -544,11 +548,13 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli controlsHideTimer = window.setTimeout(() => { if (activeDriver.paused || isPointerOverBottomBar) return; bottomBar.classList.add('minimal'); + playerWrap.classList.add('cursorHidden'); }, CONTROLS_HIDE_DELAY_MS); } function showControls() { bottomBar.classList.remove('minimal'); + playerWrap.classList.remove('cursorHidden'); scheduleControlsHide(); } From 40e019cd818e646444461048eb4f8d274b3473eb Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 15 Sep 2026 22:51:36 +0200 Subject: [PATCH 3/4] Correct docs/comments left stale by the Limit removal --- client/src/tiles.ts | 13 ++++++++----- docs/architecture.md | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/client/src/tiles.ts b/client/src/tiles.ts index 4d85f70..cd6be9d 100644 --- a/client/src/tiles.ts +++ b/client/src/tiles.ts @@ -140,11 +140,14 @@ export function renderTilesInChunks(container: HTMLElement, items: ItemDto[], ch * Wires a search to show/hide `itemSelector` elements by name, * live as you type - client-side only, filtering whatever's already * rendered rather than a server round trip per keystroke. Deliberately - * simple for that reason: a library page tops out at a few hundred items - * (see getLibraryItems's own Limit), well within "just filter the DOM" - * territory - shared by views/library, views/artist, views/album so the - * three catalog pages that asked for this all behave identically instead - * of drifting into three slightly different implementations. + * simple for that reason: even a library with well over a thousand items + * (see getLibraryItems/getArtists - no Limit any more, see their own + * comments) is still just a plain, already-in-the-DOM list of elements + * to a querySelectorAll/textContent check - "just filter the DOM" scales + * fine here regardless of count. Shared by views/library, views/artist, + * views/album so the three catalog pages that asked for this all behave + * identically instead of drifting into three slightly different + * implementations. * * `scope` should be wide enough to contain both the items themselves AND * a `.searchEmpty` notice element - typically the view's own `root`, not diff --git a/docs/architecture.md b/docs/architecture.md index c3b6cf4..5318e4e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2631,6 +2631,28 @@ mode a naive per-button-at-call-time wiring would have hit). **Not verified**: no Emby login reachable from this sandbox to measure the actual before/after latency against a real, large library. +**Correction, reported immediately after this shipped**: "the catalogs +are broken, not all the data is there". The `Limit: 300` both queries +above carried (`getArtists` gained one here; `getLibraryItems` already +had one, predating this section) turned out not to be the safe, +theoretical headroom it was assumed to be - confirmed directly against +the real server that this app's own "Musique" library has **1229** +distinct artists and "Films" has **421** movies, so both were silently +serving an incomplete grid, no error, no visible sign anything was +missing. Both `Limit`s removed outright, not raised to some other +guessed number - confirmed empirically that Emby returns everything +when `Limit` is omitted at all (`TotalRecordCount` matched +`Items.length` exactly for both the 421- and 1229-item cases), and the +render-side concern that motivated capping in the first place is +already `renderTilesInChunks`'s job regardless of how large the list +gets. That fix's own scheduling primitive needed a second look too: +`requestAnimationFrame` is deliberately paused by the browser for a +backgrounded/hidden tab - an ordinary thing to do while a now-possibly- +1000+-item list is still loading, and would have stalled the render +indefinitely, looking exactly like the same "data missing" symptom for +an unrelated reason. Switched to `setTimeout(fn, 0)`, which keeps +running (throttled, never paused) regardless of tab visibility. + ## Fixing the listen-party queue's layout and its missing album art Reported directly: the music bar's "File d'attente" popup looked broken, From ceea7432ef505520f2d0d62275cb2036314f8391 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 15 Sep 2026 23:45:14 +0200 Subject: [PATCH 4/4] Report watch-party streams to Emby's own Sessions API (admin dashboard) Asked for directly: Emby's admin "now playing" dashboard should see this app's streams the same way it sees a real web/mobile/TV client's. This is exactly the follow-up "Sync watch-party progress back to Emby" already anticipated and deliberately deferred at the time ("wiring clean start/stop into every transition... is real complexity for a secondary payoff") - that section also explains why this app abandoned Emby's session-shaped PlayingItems endpoints entirely in favor of direct UserData writes. Given that history (two separate rounds of a wrong-but-schema-plausible request shape for that other, now-abandoned endpoint family), this one was built checking the real API first rather than assuming. Confirmed against the real server's own /openapi.json that the actual session-lifecycle family is POST /Sessions/Playing (start), /Sessions/Playing/Progress, and /Sessions/Playing/Stopped - distinct from PlayingItems. None of the three schemas declare a required list at all, which - given the neighboring history - was read as "don't trust that": ItemId/MediaSourceId/PlaySessionId/PositionTicks are sent on every call anyway, matching what a real client obviously would, rather than risking a repeat of "technically matches the schema, still 400s". One real Emby session per PARTY MEMBER, not one per party - a faithful copy of how a real client behaves, and the actual point of "let admin see who's watching what" would be lost by collapsing everyone onto one shared entry. New server/src/services/EmbySessionService.ts mirrors PlaystateService's own best-effort shape (every failure caught and logged, never thrown) but stays its own class - PlaystateService's own opening comment is specifically about why it doesn't use a session-shaped endpoint, so folding this into it would muddy that exact distinction. Getting a MediaSourceId/PlaySessionId per member needed a new CatalogService.negotiatePlaybackSession - a fresh PlaybackInfo negotiation under that specific member's own account, since Emby scopes PlaybackInfo per user and PartyStreamService's existing shared negotiation is never guaranteed valid for reporting under everyone else's account too. Returns null on any failure rather than throwing - this only ever feeds an admin-dashboard nicety, never something that should disrupt real playback. Driven entirely from index.ts's existing periodic sweep rather than new hooks scattered across every queue-advance/track-skip call site - that sweep already visits every connected member on every ~20s tick, already positioned to notice a member's current item no longer matches their cached session and react (stop the old one if any, negotiate fresh ids, start the new one; same item as last tick just sends Progress with the cached ids). PartyMember gained the nullable embySession field this tracks. Explicit stop calls also happen at PartyService.leave() (alongside the existing CastTokenService .revokeForMember) and removeIdleParties (a backstop for a disconnect that never triggered a clean leave). Verified: full typecheck/test/build (134/134 server tests - new coverage for negotiatePlaybackSession's null-on-any-failure contract, every EmbySessionService method's exact request shape, that a non-ok response/thrown error is swallowed not propagated, and that leave() calls stop with a member's real cached session info but only when they actually had one open). Not verified: an actual live write against the real server - deliberately not attempted, since that would mean using a real account's Emby token for a genuine state-changing call, beyond the read-only diagnostic use this session's shared API key was ever meant for. So while the request shape is checked against the real schema and the per-member logic is unit-tested in full, whether Emby's dashboard actually displays these sessions the way a native client's would - correct device/client labeling in particular, since this app never sends the X-Emby-Authorization Device/Client/Version header a real client would, only X-Emby-Token - is genuinely unconfirmed. Worth watching the real dashboard the first time a party runs after this ships. Co-Authored-By: Claude Sonnet 5 --- README.md | 5 ++ docs/architecture.md | 91 +++++++++++++++++++ server/package.json | 2 +- server/src/Logger.ts | 25 +++--- server/src/index.ts | 41 +++++++++ server/src/services/CatalogService.ts | 27 ++++++ server/src/services/EmbySessionService.ts | 104 ++++++++++++++++++++++ server/src/services/PartyService.ts | 24 ++++- server/src/types.ts | 16 ++++ server/test/catalogService.test.ts | 29 ++++++ server/test/embySessionService.test.ts | 91 +++++++++++++++++++ server/test/musicQueueService.test.ts | 3 +- server/test/partyService.test.ts | 28 ++++++ 13 files changed, 472 insertions(+), 14 deletions(-) create mode 100644 server/src/services/EmbySessionService.ts create mode 100644 server/test/embySessionService.test.ts diff --git a/README.md b/README.md index fd3f6aa..9dfbd82 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,11 @@ Then put it behind your existing reverse proxy like everything else as its own always-on-top bar, duplicating the cast icon and overlapping the new bottom bar in fullscreen) in favor of fully custom play/pause, a seek bar, and mute — see docs/architecture.md +- [x] Report each party member's own playback to Emby's real Sessions + API (`/Sessions/Playing`, `/Progress`, `/Stopped`), so the admin + dashboard sees watch-party streams the same way it sees a native + client's — one session per member, under their own account — see + docs/architecture.md - [ ] Try a full party end-to-end with two real accounts (still the main gap) - [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is confirmed here — no real device/session) diff --git a/docs/architecture.md b/docs/architecture.md index 5318e4e..6ffcd0e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3123,6 +3123,97 @@ and play the stream end to end - no device reachable from this sandbox, same standing limitation as the rest of "Casting to a Chromecast". +## Making Emby's own admin dashboard see watch-party streams + +Asked for directly: Emby's admin "now playing" dashboard should see +this app's streams the same way it sees a real web/mobile/TV client's - +this is exactly the follow-up "Sync watch-party progress back to Emby" +(above) already anticipated and deliberately deferred, word for word: +"wiring clean start/stop into every transition... is real complexity +for a secondary payoff, left as a documented follow-up rather than +built speculatively." That section also already explains why: this app +switched entirely to direct `UserData` writes for resume-position/ +watched-history, abandoning Emby's session-shaped `PlayingItems` +endpoints, precisely because they need a real session lifecycle to mean +anything - and given that section's own history (two separate rounds of +a wrong-but-schema-plausible request shape for that *other*, +now-abandoned endpoint family), this one was built carefully, checking +the real API before writing anything rather than assuming. + +**Confirmed against the real server's own `/openapi.json`** (not +assumed) that the actual session-lifecycle family is `POST +/Sessions/Playing` (start), `/Sessions/Playing/Progress`, and +`/Sessions/Playing/Stopped` - distinct from the `PlayingItems` family +`PlaystateService` already walked away from. None of the three schemas +declare a `required` list at all, which - given this exact neighboring +history - was read as "don't trust that," not "everything's optional +in practice": `ItemId`/`MediaSourceId`/`PlaySessionId`/`PositionTicks` +are sent on every call anyway, matching what a real client obviously +would, rather than risking a repeat of the earlier "technically matches +the schema, still 400s" experience. + +**One real Emby session per PARTY MEMBER, not one per party** - a +faithful copy of how a real client behaves (every device/account gets +its own), and the actual point of "let admin see who's watching what" +would be lost by collapsing every member onto one shared entry. New +`server/src/services/EmbySessionService.ts` (`start`/`progress`/`stop`) +mirrors `PlaystateService`'s own shape closely - same best-effort +philosophy (every failure caught and logged, never thrown), same +`Logger.playstate` category (broadened to describe both) - but is kept +as its own class rather than folded into `PlaystateService`, since that +class's entire opening comment is specifically about why it *doesn't* +use a session-shaped endpoint; mixing the two would muddy exactly the +distinction that comment goes out of its way to draw. + +**Getting a `MediaSourceId`/`PlaySessionId` per member** needed a new +`CatalogService.negotiatePlaybackSession(session, itemId)` - a fresh +`PlaybackInfo` negotiation under that specific member's own account. +This can't reuse `PartyStreamService`'s existing shared negotiation: +that one is negotiated once, under whichever member happened to +trigger it first, and Emby scopes `PlaybackInfo` per user - never +guaranteed valid for reporting under everyone else's own account too. +Returns `null` on any failure rather than throwing (an expired token, +Emby unreachable, no source) - unlike `getMediaSource`'s own hard +failure (which feeds a real ffmpeg source and has to fail loudly), this +one only ever feeds an admin-dashboard nicety. + +**Driven entirely from index.ts's existing periodic sweep**, not new +hooks scattered across every queue-advance/track-skip call site the way +`PlaystateService.markPlayed` needed. That sweep already visits every +connected member on every ~20s tick - exactly positioned to notice +"this member's current item no longer matches `PartyMember +.embySession.itemId`" and react: stop the old session if there was +one, negotiate fresh ids, start the new one. The same item as last tick +just sends a Progress report reusing the already-cached ids - no +renegotiation needed for the common case. `PartyMember` gained the +nullable `embySession` field this reads/writes (same null-until-known +shape as `embyToken`/`deviceId`). Explicit `stop` calls also happen at +the two places a member's session should end promptly rather than +waiting on the next sweep tick to just stop reporting: `PartyService +.leave()` (alongside the existing `CastTokenService.revokeForMember`) +and `removeIdleParties` (a defensive backstop for a member whose own +disconnect-driven `leave()` never fired for some reason - a real gap +would otherwise leave a stale dashboard entry indefinitely, not just +until the next tick). + +Verified: full `typecheck`/`test`/`build` (140/140 server tests - new +coverage for `negotiatePlaybackSession`'s null-on-any-failure +contract, every `EmbySessionService` method's exact request shape and +that a non-ok response/thrown error is swallowed not propagated, and +that `leave()` calls `stop` with a member's real cached session info +but only when they actually had one open). **Not verified**: an actual +live write against the real server - deliberately not attempted from +here, since doing so would mean using a real account's Emby token for +a genuine state-changing call, beyond the read-only diagnostic use +this session's shared API key was ever meant for. So while the request +shape is checked against the real schema and the per-member logic is +unit-tested in full, whether Emby's dashboard actually displays these +sessions the way a native client's would - correct device/client +labeling in particular, since this app never sends the `X-Emby- +Authorization` Device/Client/Version header a real client would, only +`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real +dashboard the first time a party runs after this ships. + ## Known limitations - Playback always goes through `hls.js` against whatever Emby's diff --git a/server/package.json b/server/package.json index 91f3d82..1631345 100644 --- a/server/package.json +++ b/server/package.json @@ -9,7 +9,7 @@ "start": "node dist/index.js", "dev": "tsx watch src/index.ts", "typecheck": "tsc -p tsconfig.test.json", - "test": "tsx --test test/castTokenService.test.ts test/catalogService.test.ts test/embyClient.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts" + "test": "tsx --test test/castTokenService.test.ts test/catalogService.test.ts test/embyClient.test.ts test/embySessionService.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts" }, "engines": { "node": ">=18" diff --git a/server/src/Logger.ts b/server/src/Logger.ts index f733398..bec4633 100644 --- a/server/src/Logger.ts +++ b/server/src/Logger.ts @@ -66,17 +66,20 @@ export class Logger { console.log(`[watchparty:transcode] ${new Date().toISOString()} ${message}`); } - /** The party watch-state sync (index.ts's periodic sweep, and - * PartyService.advanceQueue/MusicQueueService.playNext's own item- - * transition calls - see PlaystateService) - logs every outgoing - * request (method + path, and the POST body for the UserData write) - * and its outcome, not just failures: this is a best-effort - * background sync with nothing else watching it, so `docker logs | - * grep watchparty:playstate` needs to be enough on its own to see - * exactly what was sent and whether Emby accepted it, without cross- - * referencing `emby` above. Safe to log in full here (unlike `stream` - * above) - the real embyToken travels as a header, never logged, and - * PlaystateService's own tests confirm it never leaks into these lines. */ + /** Every per-member background write this app makes to Emby under + * someone's own account - the party watch-state sync (index.ts's + * periodic sweep, and PartyService.advanceQueue/MusicQueueService + * .playNext's own item-transition calls - see PlaystateService), and + * the parallel Sessions-API lifecycle reported alongside it (see + * EmbySessionService). Logs every outgoing request (method + path, + * and the POST body for the UserData write) and its outcome, not + * just failures: this is a best-effort background sync with nothing + * else watching it, so `docker logs | grep watchparty:playstate` + * needs to be enough on its own to see exactly what was sent and + * whether Emby accepted it, without cross-referencing `emby` above. + * Safe to log in full here (unlike `stream` above) - the real + * embyToken travels as a header, never logged, and both services' + * own tests confirm it never leaks into these lines. */ static playstate(message: string): void { console.log(`[watchparty:playstate] ${new Date().toISOString()} ${message}`); } diff --git a/server/src/index.ts b/server/src/index.ts index 1e8087e..bbed5f5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -14,6 +14,7 @@ import { MusicQueueService } from './services/MusicQueueService'; import { TranscodeService } from './services/TranscodeService'; import { PartyStreamService } from './services/PartyStreamService'; import { PlaystateService } from './services/PlaystateService'; +import { EmbySessionService } from './services/EmbySessionService'; import { CastTokenService } from './services/CastTokenService'; import { SocketGateway } from './realtime/SocketGateway'; import type { ClientToServerEvents, EmbySession, MediaType, QueueItem, ServerToClientEvents } from './types'; @@ -606,6 +607,17 @@ setInterval(() => PartyStreamService.reapIdleSessions(30 * 60 * 1000), 5 * 60 * // Marking an item Played (once the party moves past it) is handled // separately, at the source of that transition - see // PartyService.advanceQueue and MusicQueueService.playNext. +// +// Also where EmbySessionService's own start/progress/stop lifecycle is +// driven from, rather than scattered across every queue-advance/track- +// skip call site the way markPlayed needed to be - this loop already +// visits every connected member on every tick, so it's already in +// exactly the right place to notice "this member's current item just +// changed" (member.embySession.itemId no longer matches) and react: +// stop the old session if there was one, negotiate a fresh +// MediaSourceId/PlaySessionId under their own account, and start the +// new one. Same item as last tick just reports Progress with the +// already-cached ids - no renegotiation needed for that, the common case. setInterval(() => { for (const party of PartyService.listActive()) { const state = PartyService.getCurrentPlaybackState(party); @@ -613,6 +625,35 @@ setInterval(() => { for (const member of party.members.values()) { if (!member.connection || !member.embyToken) continue; PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks); + + if (member.embySession && member.embySession.itemId === state.itemId) { + const s = member.embySession; + EmbySessionService.progress(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, state.positionTicks, state.isPaused); + continue; + } + + // The item just changed for them (or this is the first tick since + // they started watching/listening) - stop whatever they had open + // before, if anything, then negotiate and start a fresh one. + const previous = member.embySession; + const embyToken = member.embyToken; + member.embySession = null; + if (previous) { + EmbySessionService.stop(embyToken, previous.itemId, previous.mediaSourceId, previous.playSessionId, member.positionTicks); + } + // CatalogService's own methods all take a full EmbySession, for + // consistency with every live user-facing route that already has + // one from requireSession - this sweep only ever has a member's + // bare stored token, same situation PlaystateService/ + // EmbySessionService are already in. serverId/createdUtc are safe + // to fake: negotiatePlaybackSession (like every per-member Emby + // write in this app) never reads either, only embyToken/userId. + const fakeSession: EmbySession = { embyToken, userId: member.userId, userName: member.userName, serverId: '', createdUtc: 0 }; + CatalogService.negotiatePlaybackSession(fakeSession, state.itemId).then((negotiated) => { + if (!negotiated) return; + member.embySession = { itemId: state.itemId, mediaSourceId: negotiated.mediaSourceId, playSessionId: negotiated.playSessionId }; + EmbySessionService.start(embyToken, state.itemId, negotiated.mediaSourceId, negotiated.playSessionId, state.positionTicks, state.isPaused); + }); } } }, 20 * 1000); diff --git a/server/src/services/CatalogService.ts b/server/src/services/CatalogService.ts index 9bb13fe..1fe393f 100644 --- a/server/src/services/CatalogService.ts +++ b/server/src/services/CatalogService.ts @@ -307,6 +307,33 @@ class CatalogServiceClass { return { id: source.Id, container: source.Container || 'mkv', streams: source.MediaStreams || [] }; } + /** + * A fresh PlaybackInfo negotiation under `session`'s own account, + * purely to get the MediaSourceId/PlaySessionId EmbySessionService + * needs to open a real Emby Sessions-API entry for this member - see + * its own class comment for why every party member needs their own, + * rather than reusing the one PartyStreamService already negotiated + * for the party's actual shared relay (that one was negotiated once, + * under whichever member happened to trigger it first - never + * guaranteed valid for every OTHER member's own account, since Emby + * scopes PlaybackInfo per user). Null on any failure (an expired + * token, Emby unreachable, no source at all) rather than throwing - + * this is reported to an admin dashboard as a nice-to-have, never + * something that should be allowed to disrupt real playback the way + * getMediaSource's own hard failure is allowed to (that one feeds an + * actual ffmpeg source). + */ + async negotiatePlaybackSession(session: EmbySession, itemId: string): Promise<{ mediaSourceId: string; playSessionId: string } | null> { + try { + const info = await this.fetchJson(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session, 'Impossible de négocier une session de lecture.'); + const source = (info.MediaSources || [])[0]; + if (!source?.Id || !info.PlaySessionId) return null; + return { mediaSourceId: source.Id, playSessionId: info.PlaySessionId }; + } catch { + return null; + } + } + // --- Direct proxying (images/video/audio) - these need the raw // Express req/res, unlike everything above which just returns JSON. --- diff --git a/server/src/services/EmbySessionService.ts b/server/src/services/EmbySessionService.ts new file mode 100644 index 0000000..3ae1cd7 --- /dev/null +++ b/server/src/services/EmbySessionService.ts @@ -0,0 +1,104 @@ +'use strict'; + +import { EmbyClient } from '../emby/EmbyClient'; +import { Logger } from '../Logger'; + +/** + * Reports a party member's own playback to Emby's real Sessions API + * (`POST /Sessions/Playing`, `/Sessions/Playing/Progress`, + * `/Sessions/Playing/Stopped`) - the piece of session lifecycle + * docs/architecture.md's "Sync watch-party progress back to Emby" + * section deliberately deferred at the time ("wiring clean start/stop + * into every transition... is real complexity for a secondary payoff"). + * Asked for directly since: without this, this app's own streams never + * showed up in Emby's admin "now playing" dashboard the way a real web/ + * mobile/TV client's would - only the resume-position/watched-history + * UserData (PlaystateService) was ever visible anywhere, and only by + * opening the item itself, not from the dashboard's live session list. + * + * One real Emby session per PARTY MEMBER, not one per party - a + * faithful copy of how a real client behaves (every device/account gets + * its own), and the actual point of the request ("let admin see who's + * watching what") would be lost by collapsing every member onto one + * shared entry. Every call needs the member's own `embyToken` (the same + * source every other per-member Emby write in this app already uses - + * see `PartyMember.embyToken`/`PlaystateService`) - Emby's Sessions API + * is inherently per-account, there's no equivalent of "the party's" own + * session to report against instead. + * + * `MediaSourceId`/`PlaySessionId` (see `CatalogService + * .negotiatePlaybackSession`) are required in practice, not just + * present in the schema - confirmed by reading the real server's own + * `/openapi.json` rather than assumed, after this exact neighboring + * area (`PlaystateService`'s own history) already got bitten twice by + * an Emby endpoint accepting a request shape that looked complete but + * wasn't. `index.ts`'s periodic sweep is what actually drives the + * start/progress/stop transitions - see its own comment for why that + * one place could cover this instead of scattering hooks across every + * queue-advance/track-skip call site the way `PlaystateService + * .markPlayed` needed to. + * + * Best-effort throughout, exactly like `PlaystateService`: any failure + * is caught and logged here, never thrown - none of this may ever + * disrupt real playback over an admin-dashboard nicety. + */ +class EmbySessionServiceClass { + /** Called once per member when they start watching/listening to + * `itemId` - a fresh join, or the party moving to a new item while + * they're already a member (see index.ts's sweep, which detects that + * by comparing against `PartyMember.embySession.itemId`). */ + async start(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise { + await this.report('/Sessions/Playing', embyToken, { + ItemId: itemId, + MediaSourceId: mediaSourceId, + PlaySessionId: playSessionId, + PositionTicks: Math.round(positionTicks), + IsPaused: isPaused, + PlayMethod: 'Transcode', + CanSeek: true + }); + } + + /** Called periodically (~20s, alongside `PlaystateService + * .updatePosition` - see index.ts's sweep) for every member with a + * live session already open for their current item. */ + async progress(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise { + await this.report('/Sessions/Playing/Progress', embyToken, { + ItemId: itemId, + MediaSourceId: mediaSourceId, + PlaySessionId: playSessionId, + PositionTicks: Math.round(positionTicks), + IsPaused: isPaused, + PlayMethod: 'Transcode' + }); + } + + /** Called once per member when they stop watching/listening to + * `itemId` - leaving the party (`PartyService.leave`), the item + * changing (index.ts's sweep), or the party being destroyed for + * inactivity (`PartyService.removeIdleParties`). */ + async stop(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number): Promise { + await this.report('/Sessions/Playing/Stopped', embyToken, { + ItemId: itemId, + MediaSourceId: mediaSourceId, + PlaySessionId: playSessionId, + PositionTicks: Math.round(positionTicks) + }); + } + + /** Every call logs its own outgoing request + outcome - see + * Logger.playstate's own comment for why this needs to be enough on + * its own, greppable, to answer "is this actually reaching Emby, and + * with what" for this service too, not just PlaystateService. */ + private async report(path: string, embyToken: string, body: Record): Promise { + Logger.playstate(`-> POST ${path} itemId=${body.ItemId}`); + try { + const res = await EmbyClient.fetch(path, embyToken, { method: 'POST', body }); + Logger.playstate(`<- ${res.status} ${res.ok ? 'ok' : 'FAILED'} ${path} itemId=${body.ItemId}`); + } catch (err) { + Logger.playstate(`<- threw ${path} itemId=${body.ItemId}: ${(err as Error).message}`); + } + } +} + +export const EmbySessionService = new EmbySessionServiceClass(); diff --git a/server/src/services/PartyService.ts b/server/src/services/PartyService.ts index 0e9c46d..6be4ae7 100644 --- a/server/src/services/PartyService.ts +++ b/server/src/services/PartyService.ts @@ -7,6 +7,7 @@ import { TranscodeService } from './TranscodeService'; import { PartyStreamService } from './PartyStreamService'; import { PlaystateService } from './PlaystateService'; import { CastTokenService } from './CastTokenService'; +import { EmbySessionService } from './EmbySessionService'; import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types'; /** @@ -157,6 +158,15 @@ class PartyServiceClass { party.lastActivityUtc = Date.now(); MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset) CastTokenService.revokeForMember(party.id, userId); // no-op if they never cast - see its own comment for why this runs unconditionally, not just on an explicit "stop casting" + // No-op if they never had a live Emby Sessions-API entry (never + // connected, or the periodic sweep hadn't reached them yet) - see + // EmbySessionService's own comment. Fire-and-forget, same as every + // other per-member Emby report in this app - it already swallows its + // own failures, and leaving the party must never wait on Emby. + if (leavingMember?.embyToken && leavingMember.embySession) { + const s = leavingMember.embySession; + EmbySessionService.stop(leavingMember.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, leavingMember.positionTicks); + } let newHostUserId: string | null = party.hostUserId; if (wasHost) { @@ -256,6 +266,17 @@ class PartyServiceClass { MusicQueueService.destroy(party); // same leak this class's own destroy path guards against TranscodeService.endSession(party.id); // same leak - see the "leave" destroy path above PartyStreamService.endSession(party.id); + // Same reasoning, for any member whose own "leave" (a clean + // disconnect) never fired for whatever reason - a real gap would + // otherwise leave a stale entry sitting in Emby's own dashboard + // indefinitely, not just until the next sweep tick the way a + // still-connected member's stale Progress report would. + for (const member of party.members.values()) { + if (member.embyToken && member.embySession) { + const s = member.embySession; + EmbySessionService.stop(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, member.positionTicks); + } + } const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000); Logger.party(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`); } @@ -311,7 +332,8 @@ class PartyServiceClass { embyToken: null, isPaused: true, positionTicks: 0, - lastUpdatedUtc: Date.now() + lastUpdatedUtc: Date.now(), + embySession: null }; } } diff --git a/server/src/types.ts b/server/src/types.ts index fa44aa2..bc30e15 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -255,6 +255,22 @@ export interface PartyMember { lastUpdatedUtc: number; lastPauseRequestUtc?: number; lastResumeRequestUtc?: number; + /** This member's own live Emby Sessions-API entry, if one's currently + * open - see EmbySessionService. Null whenever they don't have one + * (not connected/no token yet, or the periodic sweep hasn't started + * one for their current item yet). `itemId` is what the sweep compares + * against on each tick to notice an item change (stop the old + * session, start a fresh one) - `mediaSourceId`/`playSessionId` are + * cached here specifically so a Progress report can reuse them + * without renegotiating PlaybackInfo on every single tick. */ + embySession: EmbyPlaybackSession | null; +} + +/** See PartyMember.embySession's own comment. */ +export interface EmbyPlaybackSession { + itemId: string; + mediaSourceId: string; + playSessionId: string; } /** The full internal party record - never sent to the client as-is, see diff --git a/server/test/catalogService.test.ts b/server/test/catalogService.test.ts index 2a14a07..4bbf06c 100644 --- a/server/test/catalogService.test.ts +++ b/server/test/catalogService.test.ts @@ -169,6 +169,35 @@ describe('CatalogService', () => { ); }); + it('negotiatePlaybackSession returns the MediaSourceId/PlaySessionId a Sessions-API report needs', async (t) => { + t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ + MediaSources: [{ Id: 'src1' }], + PlaySessionId: 'play1' + })); + + const result = await CatalogService.negotiatePlaybackSession(session, 'item1'); + + assert.deepEqual(result, { mediaSourceId: 'src1', playSessionId: 'play1' }); + }); + + it('negotiatePlaybackSession returns null, not a throw, when Emby has no media source at all', async (t) => { + t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [], PlaySessionId: 'play1' })); + + assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null); + }); + + it('negotiatePlaybackSession returns null when Emby omits PlaySessionId, even with a real source', async (t) => { + t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src1' }] })); + + assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null); + }); + + it('negotiatePlaybackSession returns null, not a throw, when the negotiation itself fails', async (t) => { + t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 500 }) as unknown as Response); + + assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null); + }); + it('getPlaylistItemsRaw asks Emby\'s own /Playlists endpoint, in order', async (t) => { let capturedPath = ''; t.mock.method(EmbyClient, 'fetch', async (path: string) => { diff --git a/server/test/embySessionService.test.ts b/server/test/embySessionService.test.ts new file mode 100644 index 0000000..199ad26 --- /dev/null +++ b/server/test/embySessionService.test.ts @@ -0,0 +1,91 @@ +// Exercises EmbySessionService directly against a mocked EmbyClient - no +// real Emby server involved. Covers the request shape each of the three +// Sessions-API calls sends and that a failure (a non-ok response, or a +// thrown error) is swallowed rather than propagated - see the class's +// own comment for why none of this may ever be allowed to disrupt real +// playback over an admin-dashboard nicety. +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { EmbySessionService } from '../src/services/EmbySessionService'; +import { EmbyClient } from '../src/emby/EmbyClient'; + +function okResponse(): Response { + return new Response(null, { status: 204 }) as unknown as Response; +} + +describe('EmbySessionService', () => { + it('start() POSTs to /Sessions/Playing with the negotiated ids and the given position/pause state', async (t) => { + let capturedPath = ''; + let capturedToken = ''; + let capturedBody: unknown = null; + t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: { body?: unknown }) => { + capturedPath = path; + capturedToken = token; + capturedBody = options.body; + return okResponse(); + }); + + await EmbySessionService.start('tok1', 'item1', 'src1', 'play1', 12_345, false); + + assert.equal(capturedPath, '/Sessions/Playing'); + assert.equal(capturedToken, 'tok1'); + assert.deepEqual(capturedBody, { + ItemId: 'item1', + MediaSourceId: 'src1', + PlaySessionId: 'play1', + PositionTicks: 12_345, + IsPaused: false, + PlayMethod: 'Transcode', + CanSeek: true + }); + }); + + it('progress() POSTs to /Sessions/Playing/Progress', async (t) => { + let capturedPath = ''; + t.mock.method(EmbyClient, 'fetch', async (path: string) => { + capturedPath = path; + return okResponse(); + }); + + await EmbySessionService.progress('tok1', 'item1', 'src1', 'play1', 999, true); + + assert.equal(capturedPath, '/Sessions/Playing/Progress'); + }); + + it('stop() POSTs to /Sessions/Playing/Stopped', async (t) => { + let capturedPath = ''; + let capturedBody: unknown = null; + t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options: { body?: unknown }) => { + capturedPath = path; + capturedBody = options.body; + return okResponse(); + }); + + await EmbySessionService.stop('tok1', 'item1', 'src1', 'play1', 42); + + assert.equal(capturedPath, '/Sessions/Playing/Stopped'); + assert.deepEqual(capturedBody, { ItemId: 'item1', MediaSourceId: 'src1', PlaySessionId: 'play1', PositionTicks: 42 }); + }); + + it('rounds a fractional PositionTicks rather than sending a non-integer', async (t) => { + let capturedBody: { PositionTicks?: number } = {}; + t.mock.method(EmbyClient, 'fetch', async (_path: string, _token: string, options: { body?: unknown }) => { + capturedBody = options.body as { PositionTicks?: number }; + return okResponse(); + }); + + await EmbySessionService.progress('tok1', 'item1', 'src1', 'play1', 12_345.6, false); + + assert.equal(capturedBody.PositionTicks, 12_346); + }); + + it('a non-ok response is logged, not thrown', async (t) => { + t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 401 }) as unknown as Response); + await assert.doesNotReject(() => EmbySessionService.start('tok1', 'item1', 'src1', 'play1', 0, false)); + }); + + it('a thrown network error is logged, not propagated', async (t) => { + t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('network down'); }); + await assert.doesNotReject(() => EmbySessionService.stop('tok1', 'item1', 'src1', 'play1', 0)); + }); +}); diff --git a/server/test/musicQueueService.test.ts b/server/test/musicQueueService.test.ts index 1aa7bc8..20a0f81 100644 --- a/server/test/musicQueueService.test.ts +++ b/server/test/musicQueueService.test.ts @@ -49,7 +49,8 @@ function fakeMember(userId: string, userName: string, embyToken: string): PartyM embyToken, isPaused: false, positionTicks: 0, - lastUpdatedUtc: Date.now() + lastUpdatedUtc: Date.now(), + embySession: null }; } diff --git a/server/test/partyService.test.ts b/server/test/partyService.test.ts index 665d275..f3ea270 100644 --- a/server/test/partyService.test.ts +++ b/server/test/partyService.test.ts @@ -4,6 +4,7 @@ import { PartyService } from '../src/services/PartyService'; import { MusicQueueService } from '../src/services/MusicQueueService'; import { PlaystateService } from '../src/services/PlaystateService'; import { CastTokenService } from '../src/services/CastTokenService'; +import { EmbySessionService } from '../src/services/EmbySessionService'; import type { AppSocket } from '../src/types'; describe('PartyService', () => { @@ -127,6 +128,33 @@ describe('PartyService', () => { assert.deepEqual(calls, [[party.id, 'u-bob']]); }); + it('leave() stops whatever Emby Sessions-API entry the leaving member had open', (t) => { + const calls: unknown[][] = []; + t.mock.method(EmbySessionService, 'stop', async (...args: unknown[]) => { calls.push(args); }); + + const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video'); + const bob = PartyService.join(party, 'u-bob', 'Bob'); + bob.embyToken = 'tok-bob'; + bob.positionTicks = 555; + bob.embySession = { itemId: 'm1', mediaSourceId: 'src1', playSessionId: 'play1' }; + + PartyService.leave(party, 'u-bob'); + + assert.deepEqual(calls, [['tok-bob', 'm1', 'src1', 'play1', 555]]); + }); + + it('leave() does not call EmbySessionService.stop for a member with no open session', (t) => { + let called = false; + t.mock.method(EmbySessionService, 'stop', async () => { called = true; }); + + const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video'); + PartyService.join(party, 'u-bob', 'Bob'); // embyToken/embySession both still null - see newMember + + PartyService.leave(party, 'u-bob'); + + assert.equal(called, false); + }); + it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => { const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video'); const bob = PartyService.join(party, 'u-bob', 'Bob');