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>
82 lines
4 KiB
TypeScript
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);
|
|
});
|
|
});
|