Implements a detailed spec (Google Cast / Chromecast integration) asking
for a member to cast the party's video to a Chromecast's Default Media
Receiver, with play/pause/seek shown as a remote on the sender's own
screen. The spec's description of this app's architecture (one shared
Emby stream per party, the server proxying 100% of Emby access) was
verified directly against the real code before building anything, since
it named specific existing services/routes as the integration points.
A Chromecast is a new kind of client for the existing party-stream/burn
relay: it can't carry the sender's httpOnly session cookie at all, so it
needed its own narrow way in without weakening "the server holds the
only real Emby token" the rest of the app is built around.
- New CastTokenService (same shape as AuthService: in-memory Map, opaque
crypto.randomUUID() keys, TTL-checked) issues a token scoped to one
{partyId, userId}, standing in for that member's own EmbySession.
New POST/DELETE /api/parties/:partyId/cast-token to mint/revoke one.
- The 4 routes a Chromecast actually needs (party-stream/master.m3u8,
party-stream/raw, burn/master.m3u8, burn/:segment) gained one small
fallback (sessionForStream in index.ts): try the session cookie first,
same as ever, then a castToken query param - either way the result is
the same EmbySession type, so PartyStreamService/TranscodeService/
EmbyClient needed zero changes. The image/subtitle routes stay
cookie-only, outside the spec's own 4-route list.
- PartyService.leave() now revokes whatever cast token the leaving
member held, unconditionally - a token dies the instant its owner
actually leaves the party regardless of TTL or whether the client got
a chance to call the explicit stop-casting route.
- New client/src/cast.ts is the only module touching the CAF Sender SDK
(loaded as a pinned CDN script, same pattern as hls.js/socket.io) -
exposes a small PlaybackDriver interface (currentTime/paused/play/
pause) with two implementations, one over the local <video>, one over
CAF's RemotePlayer/RemotePlayerController. This was the only new
abstraction the feature needed, not a new sync mechanism: Player.ts's
existing sendLocalState/applyRemoteState already read and write "the
player" in one place each, so pointing them at whichever driver is
active carried the host-only gate, drift-tolerance reconciliation, and
3s heartbeat over unchanged. Seek was already "the host's next
broadcast" with no separate event, so seeking on the TV is party-wide
for free; a non-host caster already only ever received state, so
redirecting that to the cast driver reproduces "still dependent on the
host" automatically.
- Cast button in the player's action bar, shown to any member (not just
the host) once cast.framework reports a receiver on the network.
Starting a cast tears down the local hls.js instance; ending one
(user-stopped, or the receiver dropping off on its own) resyncs local
playback via the same loadVideo path an audio/quality change already
uses. A real reload for any other reason (item change, burn-in/
subtitle/stream-options change) simply ends an active cast first,
rather than keeping a live session in sync with every kind of party
transition - not worth the complexity for v1.
Scoped to video parties only - the spec's every concrete reference is
video-specific, and MusicBar.ts has no comparable control surface today.
Tests: CastTokenService (issue/resolve/reissue-invalidates-the-old-one/
revoke/wrong-party-rejection), PartyService.leave()'s new call. Full
typecheck/test/build clean on both projects (115/115 server tests).
Confirmed against the real running server that the 4 streaming routes
still reject a request with no auth exactly as before, and now also
reject an invalid castToken - both a plain 401, no regression. Confirmed
in a real browser that the Cast SDK loads and initializes correctly. Not
verified: no real Chromecast or Emby login credentials in this
environment to take a cast session, or the button's own live behavior,
any further - see docs/architecture.md's "Casting to a Chromecast"
section.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
230 lines
11 KiB
TypeScript
230 lines
11 KiB
TypeScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
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 type { AppSocket } from '../src/types';
|
|
|
|
describe('PartyService', () => {
|
|
it('create() sets mediaType and seeds the host as the first member', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01 - Pilote', '/img/ep1', [
|
|
{ itemId: 'ep1', itemName: 'S01E01 - Pilote', itemImageUrl: '/img/ep1' },
|
|
{ itemId: 'ep2', itemName: 'S01E02 - Suite', itemImageUrl: '/img/ep2' }
|
|
], 'video');
|
|
|
|
assert.equal(party.mediaType, 'video');
|
|
assert.equal(party.members.size, 1);
|
|
assert.equal(party.hostUserId, 'u-alice');
|
|
assert.equal(party.burnInSubtitleIndex, null, 'no burn-in stream until the host picks one');
|
|
assert.equal(party.burnInAudioIndex, null);
|
|
assert.equal(party.textSubtitleIndex, null, 'no text subtitle pick until the host picks one');
|
|
});
|
|
|
|
it('advanceQueue moves through the fixed queue and stops at the end', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
|
|
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
|
|
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
|
|
], 'video');
|
|
|
|
assert.equal(PartyService.advanceQueue(party), true);
|
|
assert.equal(party.itemId, 'ep2');
|
|
assert.equal(PartyService.advanceQueue(party), false, 'nothing left after the last item');
|
|
});
|
|
|
|
it('advanceQueue clears the party\'s burn-in/text-subtitle choice - a new item\'s stream indices mean nothing on the old one', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
|
|
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
|
|
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
|
|
], 'video');
|
|
party.burnInSubtitleIndex = '4';
|
|
party.burnInAudioIndex = '2';
|
|
party.textSubtitleIndex = '3';
|
|
|
|
PartyService.advanceQueue(party);
|
|
|
|
assert.equal(party.burnInSubtitleIndex, null);
|
|
assert.equal(party.burnInAudioIndex, null);
|
|
assert.equal(party.textSubtitleIndex, null);
|
|
});
|
|
|
|
it('advanceQueue marks the outgoing item Played for every connected member with a token, under their own account - see PlaystateService', (t) => {
|
|
const calls: [string, string, string][] = [];
|
|
t.mock.method(PlaystateService, 'markPlayed', async (embyToken: string, userId: string, itemId: string) => {
|
|
calls.push([embyToken, userId, itemId]);
|
|
});
|
|
|
|
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
|
|
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
|
|
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
|
|
], 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
PartyService.setMemberEmbyToken(party, 'u-alice', 'tok-alice');
|
|
PartyService.setMemberEmbyToken(party, 'u-bob', 'tok-bob');
|
|
// A real socket connection isn't needed for anything else this test
|
|
// checks - just enough to pass advanceQueue's own "connected" guard,
|
|
// same one the periodic sweep in index.ts already uses.
|
|
party.members.get('u-alice')!.connection = {} as unknown as AppSocket;
|
|
party.members.get('u-bob')!.connection = {} as unknown as AppSocket;
|
|
|
|
PartyService.advanceQueue(party);
|
|
|
|
assert.deepEqual(calls.sort(), [['tok-alice', 'u-alice', 'ep1'], ['tok-bob', 'u-bob', 'ep1']].sort(), 'marks the OUTGOING item (ep1), not the new one just moved to');
|
|
});
|
|
|
|
it('advanceQueue never marks a member Played if they have no connection or no token yet', (t) => {
|
|
let called = false;
|
|
t.mock.method(PlaystateService, 'markPlayed', async () => { called = true; });
|
|
|
|
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
|
|
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
|
|
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
|
|
], 'video');
|
|
// Host never got a connection/token set at all in this test - same as
|
|
// a REST-created party nobody has actually joined a socket to yet.
|
|
|
|
PartyService.advanceQueue(party);
|
|
|
|
assert.equal(called, false);
|
|
});
|
|
|
|
it('leave(): a non-host leaving neither destroys the party nor hands off', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
|
|
const result = PartyService.leave(party, 'u-bob');
|
|
assert.equal(result.destroyed, false);
|
|
assert.equal(result.hostChanged, false);
|
|
});
|
|
|
|
it('leave(): the host leaving with someone left hands off, and party.hostUserId actually updates', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
|
|
const result = PartyService.leave(party, 'u-alice');
|
|
assert.equal(result.hostChanged, true);
|
|
assert.equal(result.newHostUserId, 'u-bob');
|
|
assert.equal(party.hostUserId, 'u-bob');
|
|
});
|
|
|
|
it('leave(): the last member leaving destroys the party', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
|
|
const result = PartyService.leave(party, 'u-alice');
|
|
assert.equal(result.destroyed, true);
|
|
assert.equal(PartyService.getById(party.id), null);
|
|
});
|
|
|
|
it('leave() revokes whatever cast token the leaving member held for this party, even when they weren\'t the host and the party survives', (t) => {
|
|
const calls: [string, string][] = [];
|
|
t.mock.method(CastTokenService, 'revokeForMember', (partyId: string, userId: string) => { calls.push([partyId, userId]); });
|
|
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
|
|
PartyService.leave(party, 'u-bob');
|
|
|
|
assert.deepEqual(calls, [[party.id, 'u-bob']]);
|
|
});
|
|
|
|
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');
|
|
bob.isPaused = false;
|
|
bob.positionTicks = 12345;
|
|
bob.deviceId = 'device-1';
|
|
|
|
// The REST "Rejoindre" route (and a second device/tab's socket "join")
|
|
// call this every time regardless of whether the caller is already a
|
|
// member - this must not reset any of the live state just set above
|
|
// (see this method's own comment for the crash that used to cause).
|
|
const bobAgain = PartyService.join(party, 'u-bob', 'Bob');
|
|
assert.equal(bobAgain, bob, 'must be the exact same member object, not a fresh one');
|
|
assert.equal(bobAgain.isPaused, false, 'a re-join must not reset live state');
|
|
assert.equal(bobAgain.positionTicks, 12345);
|
|
assert.equal(bobAgain.deviceId, 'device-1');
|
|
assert.equal(party.members.size, 2, 'still exactly one entry for bob, not a duplicate');
|
|
});
|
|
|
|
it('removeIdleParties sweeps a party whose lastActivityUtc is past the cutoff', () => {
|
|
const party = PartyService.create('u-dave', 'Dave', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
party.lastActivityUtc = Date.now() - 5 * 60 * 60 * 1000;
|
|
|
|
PartyService.removeIdleParties(4 * 60 * 60 * 1000);
|
|
assert.equal(PartyService.getById(party.id), null);
|
|
});
|
|
|
|
it('toPublicState reports youAreHost for the creator, and music:null for a video party', () => {
|
|
const party = PartyService.create('u-eve', 'Eve', 'm2', 'Un Autre Film', '/img/m2', null, 'video');
|
|
const state = PartyService.toPublicState(party, 'u-eve');
|
|
|
|
assert.equal(state.youAreHost, true);
|
|
assert.equal(state.music, null);
|
|
});
|
|
|
|
it('toPublicState exposes the party\'s current burn-in choice - a late joiner\'s getParty must see it too', () => {
|
|
const party = PartyService.create('u-eve', 'Eve', 'm2', 'Un Autre Film', '/img/m2', null, 'video');
|
|
party.burnInSubtitleIndex = '4';
|
|
party.burnInAudioIndex = '2';
|
|
|
|
const state = PartyService.toPublicState(party, 'u-eve');
|
|
|
|
assert.equal(state.burnInSubtitleIndex, '4');
|
|
assert.equal(state.burnInAudioIndex, '2');
|
|
});
|
|
|
|
it('toPublicState exposes the party\'s current text subtitle choice - a late joiner\'s getParty must see it too', () => {
|
|
const party = PartyService.create('u-eve', 'Eve', 'm2', 'Un Autre Film', '/img/m2', null, 'video');
|
|
party.textSubtitleIndex = '3';
|
|
|
|
const state = PartyService.toPublicState(party, 'u-eve');
|
|
|
|
assert.equal(state.textSubtitleIndex, '3');
|
|
});
|
|
|
|
it('setMemberEmbyToken records a member\'s own token, and is a harmless no-op for a non-member', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
|
|
PartyService.setMemberEmbyToken(party, 'u-alice', 'tok-alice');
|
|
assert.equal(party.members.get('u-alice')?.embyToken, 'tok-alice');
|
|
|
|
assert.doesNotThrow(() => PartyService.setMemberEmbyToken(party, 'u-nobody', 'tok-x'));
|
|
});
|
|
|
|
it('getCurrentPlaybackState (video) reads the host\'s own tracked position, not a follower\'s', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
const host = party.members.get('u-alice')!;
|
|
host.isPaused = false;
|
|
host.positionTicks = 5_000_000;
|
|
// A follower's own isPaused/positionTicks are never actually tracked
|
|
// today (see the method's own comment) - setting them here proves
|
|
// the result comes from the host, not whichever member happens to
|
|
// be iterated first.
|
|
const bob = party.members.get('u-bob')!;
|
|
bob.isPaused = true;
|
|
bob.positionTicks = 999;
|
|
|
|
const state = PartyService.getCurrentPlaybackState(party);
|
|
|
|
assert.deepEqual(state, { itemId: 'm1', positionTicks: 5_000_000, isPaused: false });
|
|
});
|
|
|
|
it('getCurrentPlaybackState (music) reads the shared queue position - the same for every member', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
|
|
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 60_000_000 }, 'u-alice', 'Alice');
|
|
|
|
const state = PartyService.getCurrentPlaybackState(party);
|
|
|
|
assert.equal(state?.itemId, 't1');
|
|
assert.equal(state?.isPaused, false);
|
|
assert.equal(typeof state?.positionTicks, 'number');
|
|
});
|
|
|
|
it('getCurrentPlaybackState is null when there\'s genuinely nothing playing (an empty music queue)', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
|
|
MusicQueueService.init(party, null, 'u-alice', 'Alice');
|
|
|
|
assert.equal(PartyService.getCurrentPlaybackState(party), null);
|
|
});
|
|
});
|