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>
46 lines
2.1 KiB
TypeScript
46 lines
2.1 KiB
TypeScript
// PlaystateService.reportProgress - mocks EmbyClient.fetch (not the
|
|
// global fetch it wraps), same isolation style as catalogService.test.ts,
|
|
// since this is about what PlaystateService itself sends, not
|
|
// EmbyClient's own plumbing (see embyClient.test.ts for that).
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { PlaystateService } from '../src/services/PlaystateService';
|
|
import { EmbyClient } from '../src/emby/EmbyClient';
|
|
|
|
describe('PlaystateService.reportProgress', () => {
|
|
it('POSTs to this user\'s own PlayingItems/Progress with their own token', async (t) => {
|
|
let capturedPath = '';
|
|
let capturedToken = '';
|
|
let capturedOptions: unknown;
|
|
t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: unknown) => {
|
|
capturedPath = path;
|
|
capturedToken = token;
|
|
capturedOptions = options;
|
|
return new Response('{}', { status: 200 });
|
|
});
|
|
|
|
await PlaystateService.reportProgress('tok-alice', 'u-alice', 'item1', 12_345_678, false);
|
|
|
|
assert.equal(capturedPath, '/Users/u-alice/PlayingItems/item1/Progress');
|
|
assert.equal(capturedToken, 'tok-alice');
|
|
assert.deepEqual(capturedOptions, { method: 'POST', body: { PositionTicks: 12345678, IsPaused: false } });
|
|
});
|
|
|
|
it('rounds a fractional positionTicks (Emby expects an integer)', async (t) => {
|
|
let capturedOptions: { body?: { PositionTicks?: number } } | undefined;
|
|
t.mock.method(EmbyClient, 'fetch', async (_path: string, _token: string, options: typeof capturedOptions) => {
|
|
capturedOptions = options;
|
|
return new Response('{}', { status: 200 });
|
|
});
|
|
|
|
await PlaystateService.reportProgress('tok', 'u1', 'item1', 100.7, true);
|
|
|
|
assert.equal(capturedOptions?.body?.PositionTicks, 101);
|
|
});
|
|
|
|
it('swallows a failure instead of throwing - this runs on a timer with nothing to catch it', async (t) => {
|
|
t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); });
|
|
|
|
await assert.doesNotReject(() => PlaystateService.reportProgress('tok', 'u1', 'item1', 0, false));
|
|
});
|
|
});
|