From ceea7432ef505520f2d0d62275cb2036314f8391 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 15 Sep 2026 23:45:14 +0200 Subject: [PATCH] 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');