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"
already anticipated and deliberately deferred at the time ("wiring
clean start/stop into every transition... is real complexity for a
secondary payoff") - that section also explains why this app abandoned
Emby's session-shaped PlayingItems endpoints entirely in favor of
direct UserData writes. Given that history (two separate rounds of a
wrong-but-schema-plausible request shape for that other, now-abandoned
endpoint family), this one was built checking the real API first
rather than assuming.
Confirmed against the real server's own /openapi.json that the actual
session-lifecycle family is POST /Sessions/Playing (start),
/Sessions/Playing/Progress, and /Sessions/Playing/Stopped - distinct
from PlayingItems. None of the three schemas declare a required list
at all, which - given the neighboring history - was read as "don't
trust that": ItemId/MediaSourceId/PlaySessionId/PositionTicks are sent
on every call anyway, matching what a real client obviously would,
rather than risking a repeat of "technically matches the schema, still
400s".
One real Emby session per PARTY MEMBER, not one per party - a faithful
copy of how a real client behaves, and the actual point of "let admin
see who's watching what" would be lost by collapsing everyone onto one
shared entry. New server/src/services/EmbySessionService.ts mirrors
PlaystateService's own best-effort shape (every failure caught and
logged, never thrown) but stays its own class - PlaystateService's own
opening comment is specifically about why it doesn't use a
session-shaped endpoint, so folding this into it would muddy that
exact distinction.
Getting a MediaSourceId/PlaySessionId per member needed a new
CatalogService.negotiatePlaybackSession - a fresh PlaybackInfo
negotiation under that specific member's own account, since Emby scopes
PlaybackInfo per user and PartyStreamService's existing shared
negotiation is never guaranteed valid for reporting under everyone
else's account too. Returns null on any failure rather than throwing -
this only ever feeds an admin-dashboard nicety, never something that
should disrupt real playback.
Driven entirely from index.ts's existing periodic sweep rather than new
hooks scattered across every queue-advance/track-skip call site -
that sweep already visits every connected member on every ~20s tick,
already positioned to notice a member's current item no longer matches
their cached session and react (stop the old one if any, negotiate
fresh ids, start the new one; same item as last tick just sends
Progress with the cached ids). PartyMember gained the nullable
embySession field this tracks. Explicit stop calls also happen at
PartyService.leave() (alongside the existing CastTokenService
.revokeForMember) and removeIdleParties (a backstop for a disconnect
that never triggered a clean leave).
Verified: full typecheck/test/build (134/134 server tests - new
coverage for negotiatePlaybackSession's null-on-any-failure contract,
every EmbySessionService method's exact request shape, 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, since that 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.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
258 lines
12 KiB
TypeScript
258 lines
12 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 { EmbySessionService } from '../src/services/EmbySessionService';
|
|
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('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');
|
|
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);
|
|
});
|
|
});
|