diff --git a/README.md b/README.md
index 066afae..e12d4bf 100644
--- a/README.md
+++ b/README.md
@@ -332,6 +332,11 @@ Then put it behind your existing reverse proxy like everything else
native marker detection (`EnableMarkerDetection` + its "Detect
Episode Intros" task) rather than building fingerprinting
ourselves — 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/client/src/tiles.ts b/client/src/tiles.ts
index 878cc80..cd6be9d 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();
}
@@ -129,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/client/src/views/player/Player.css b/client/src/views/player/Player.css
index f370665..04fce66 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 6528a9d..299887b 100644
--- a/client/src/views/player/Player.ts
+++ b/client/src/views/player/Player.ts
@@ -583,7 +583,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) {
@@ -597,11 +601,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();
}
diff --git a/docs/architecture.md b/docs/architecture.md
index ecd5b75..1c772f7 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,
@@ -3177,6 +3199,97 @@ actual end-to-end "does a detected marker make the button appear at
the right second" behavior is unconfirmed against real generated data -
worth a real check once the task has had time to run.
+## 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 06a7db7..a478b6f 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';
@@ -613,6 +614,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);
@@ -620,6 +632,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 d393440..8cc8537 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));
@@ -317,6 +331,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 c096c7b..d0e27bf 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -274,6 +274,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 baeff2a..cba3c2b 100644
--- a/server/test/catalogService.test.ts
+++ b/server/test/catalogService.test.ts
@@ -205,6 +205,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');