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');