watchparty/server/test/embyClient.test.ts
Nicolas 11f003d269
All checks were successful
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Successful in 16s
Sync watch-party progress back to Emby (PlaystateService)
This app has been read/proxy-only toward Emby so far. Queued right
after the playlist feature, asked for directly: while someone's in a
party, push their progress to Emby's own PlaystateService, so their own
Emby library (resume position, last-played state) stays in sync with
the watch/listen party, under their own account - not just the host's,
and not just this app's own memory of it.

Scoped to periodic progress reporting only for v1 - no explicit
start/stop calls (Emby's own "now playing" list won't show this app's
streams, and "mark fully watched" isn't guaranteed the way a clean Stop
near 100% would trigger it). Steady Progress calls alone already cover
the actual ask; wiring clean start/stop into every transition (item
change, leave, party destroy, a device takeover) is real complexity for
a secondary payoff - left as a documented follow-up.

- server/src/types.ts: PartyMember gains embyToken (null until known,
  same shape as this session's own deviceId addition) - never sent to
  the client, toPublicState's member mapping doesn't grow it.
- server/src/emby/EmbyClient.ts: fetch() now optionally takes
  method/body (JSON-encoded once, here) - every existing GET-only call
  site unaffected, both are optional.
- server/src/services/PlaystateService.ts (new): reportProgress(token,
  userId, itemId, positionTicks, isPaused) - POSTs to
  /Users/{userId}/PlayingItems/{itemId}/Progress. Catches and logs its
  own failures (new Logger.playstate) rather than throwing - this runs
  on a timer with nothing to catch a rejection, and one bad token or a
  transient Emby hiccup must never touch real playback.
- server/src/services/PartyService.ts: new setMemberEmbyToken (records
  a member's own token) and getCurrentPlaybackState (video: the host's
  own tracked position - there's no per-follower tracking today, a
  reasonable approximation given the player's own drift correction;
  music: the one shared, server-authoritative position every member
  already sees the same way). The one real piece of logic here is pure
  and unit-tested on its own, same shape as this session's
  absoluteToRelativeIndex/splitPlaylistByMediaType.
- server/src/realtime/SocketGateway.ts: attach()'s getSession callback
  widened to expose embyToken - it was already being passed the full
  EmbySession (AuthService.getSession), just typed down to {userId,
  userName} before anything downstream could read the rest. The "join"
  handler now also records member.embyToken alongside deviceId.
- server/src/index.ts: setMemberEmbyToken called after each of the 4
  REST create/join call sites, so a member has a valid token from the
  moment they're a member, not only once their socket first connects.
  New ~20s setInterval (same shape as the existing removeIdleParties/
  TranscodeService.reapIdleSessions timers): for every active party,
  getCurrentPlaybackState once, then reportProgress for every connected
  member with a known token.
- server/test/embyClient.test.ts, partyService.test.ts,
  playstateService.test.ts (new): the new body-encoding path (and that
  a plain GET stays untouched), getCurrentPlaybackState's video/music/
  empty branches, setMemberEmbyToken, and that reportProgress's request
  shape is right and a thrown failure never propagates.
- docs/architecture.md, README.md: design + the deliberate v1 scope and
  the follower-position approximation spelled out.

No client changes - this is a pure server-side background process with
nothing for the browser to render or react to.

Verified: full typecheck+test+build on both projects (71/71 server
tests, 9 new). No Emby server is reachable from this dev environment,
so the actual endpoint/body shape is unverified end-to-end - flagged
explicitly in the docs, the third such Emby-API assumption this session
(after the ffmpeg direct-stream URL and the Playlists endpoint, both
fine once actually tried).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 19:21:16 +02:00

82 lines
4 KiB
TypeScript

// EmbyClient.fetch itself (not mocked away, unlike catalogService.test.ts,
// which replaces it wholesale to isolate CatalogService) - specifically
// the error-logging path added after several rounds of not being able to
// see what Emby actually said on a failure (see docs/architecture.md's
// player-track-selection section). Mocks the GLOBAL fetch, since that's
// what EmbyClient.fetch itself calls.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { EmbyClient } from '../src/emby/EmbyClient';
describe('EmbyClient.fetch', () => {
it('logs Emby\'s status and response body on a failure, without consuming the body the real caller still needs', async (t) => {
const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(globalThis, 'fetch', async () => new Response('Server.MediaEncoding.Api boom', { status: 500 }));
const res = await EmbyClient.fetch('/Videos/91988/hls1/main/0.ts?PlaySessionId=abc', 'tok');
const embyLines = logged.filter((l) => l.includes('[watchparty:emby]'));
assert.equal(embyLines.length, 1);
assert.ok(embyLines[0]!.includes('500'));
assert.ok(embyLines[0]!.includes('Server.MediaEncoding.Api boom'), 'the real body text should make it into the log line');
assert.ok(embyLines[0]!.includes('/Videos/91988/hls1/main/0.ts'));
// The caller (proxyResponse/fetchJson) still needs to read this same
// response's body itself - logErrorResponse must read a *clone*, not
// the original, or this would come back empty/throw.
assert.equal(await res.text(), 'Server.MediaEncoding.Api boom');
});
it('redacts api_key from the logged path - Emby\'s own TranscodingUrl can carry one', async (t) => {
const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(globalThis, 'fetch', async () => new Response('nope', { status: 502 }));
await EmbyClient.fetch('/Videos/1/master.m3u8?api_key=super-secret&MediaSourceId=x', 'tok');
const line = logged.find((l) => l.includes('[watchparty:emby]'))!;
assert.ok(!line.includes('super-secret'), 'the real key must never reach the log');
assert.ok(line.includes('api_key=REDACTED'));
});
it('logs nothing extra on a successful response - only failures are interesting', async (t) => {
const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(globalThis, 'fetch', async () => new Response('{}', { status: 200 }));
await EmbyClient.fetch('/Users/u1/Views', 'tok');
assert.equal(logged.filter((l) => l.includes('[watchparty:emby]')).length, 0);
});
it('JSON-encodes a body and sets method/Content-Type - PlaystateService\'s own POST calls need this', async (t) => {
let capturedInit: RequestInit | undefined;
t.mock.method(globalThis, 'fetch', async (_url: string, init: RequestInit) => {
capturedInit = init;
return new Response('{}', { status: 200 });
});
await EmbyClient.fetch('/Users/u1/PlayingItems/item1/Progress', 'tok', {
method: 'POST',
body: { PositionTicks: 123, IsPaused: false }
});
assert.equal(capturedInit?.method, 'POST');
assert.equal((capturedInit?.headers as Record<string, string>)['Content-Type'], 'application/json');
assert.equal(capturedInit?.body, JSON.stringify({ PositionTicks: 123, IsPaused: false }));
});
it('a plain GET (no body) never gets a Content-Type header - every existing call site relies on this staying untouched', async (t) => {
let capturedInit: RequestInit | undefined;
t.mock.method(globalThis, 'fetch', async (_url: string, init: RequestInit) => {
capturedInit = init;
return new Response('{}', { status: 200 });
});
await EmbyClient.fetch('/Users/u1/Views', 'tok');
assert.equal(capturedInit?.method, undefined);
assert.equal((capturedInit?.headers as Record<string, string>)['Content-Type'], undefined);
});
});