Asked for directly, right after the client-side <track> redesign closed
every *text* subtitle case: PGS/VobSub/DVB subtitles (Blu-ray/DVD rips)
are bitmaps, not text - there's nothing for Emby's Stream.vtt conversion
to extract, so a native <track> fundamentally can't render one. Rather
than keep asking Emby's own transcode to burn these in (the exact thing
that kept failing in earlier sessions - missing fontconfig, then an
unrelated "Error starting ffmpeg"), this runs ffmpeg ourselves: a PGS
stream decodes like a second video track, and ffmpeg's `overlay` filter
composites it directly - no text rendering, no libass, no fontconfig
anywhere in this path.
One shared stream per PARTY, not per viewer - explicitly asked for after
the initial plan: every member plugs into the exact same server-emitted
stream, mirroring "host drives, everyone follows" rather than a
per-viewer model. Picking a burn-in subtitle is therefore a host-only,
party-wide choice, like play/pause/next already are.
- server/src/services/TranscodeService.ts (new): one ffmpeg session per
partyId. getOrStartSession negotiates PlaybackInfo (via the new
CatalogService.getMediaSource), converts Emby's absolute
MediaStreams[].Index into ffmpeg's type-relative 0:s:N/0🅰️N
(absoluteToRelativeIndex - pure, unit-tested), and spawns ffmpeg
against Emby's own direct/static file endpoint (TODO(verify) - no
Emby server reachable from this dev environment, see the function's
own comment). Output is real progressive HLS to a temp dir, served by
two new routes that poll briefly for the next file. endSession/
reapIdleSessions handle teardown; PartyService's destroy path and
advanceQueue both end a party's session immediately, the idle sweep
is the backstop.
- server/src/services/PartyService.ts: Party/PartyPublicState gain
burnInSubtitleIndex/burnInAudioIndex (null together = no burn-in);
advanceQueue clears them (a new item's indices mean nothing on the
old one).
- server/src/realtime/SocketGateway.ts: new host-only "setBurnInSubtitle"
handler broadcasting "burnInChanged" - same shape as "next"/
"itemChanged".
- server/src/services/CatalogService.ts: new getMediaSource() (id/
container/streams together - what TranscodeService needs); extended
the local PlaybackInfoResponse type with Container.
- server/src/Logger.ts: new Logger.transcode().
- server/src/index.ts: GET /api/catalog/burn/:partyId/master.m3u8 and
.../:segment (membership-checked, same pattern as the party REST
routes); a dedicated idle-reap interval for TranscodeService.
- server/Dockerfile: `apk add ffmpeg` in the runtime stage - no font
packages needed, unlike Emby's own text-subtitle burn-in.
- client/src/api/party.ts, client/src/api/embyApi.ts: setBurnInSubtitle/
onBurnInChanged on PartyConnection, burnInStreamUrl().
- client/src/views/player/Player.{html,css,ts}: the subtitle picker's
image-track options go from unconditionally disabled to host-only
selectable, labeled to say picking one restarts the stream for
everyone. Every member (host included) only ever reacts to the
resulting "burnInChanged" broadcast - never applies a pick
optimistically, same discipline the rest of the player already uses.
Audio/quality are disabled while a burn-in is active (one fixed
encode); a follower's subtitle select is disabled too (nothing left
for them to meaningfully pick).
- server/test/transcodeService.test.ts (new): absoluteToRelativeIndex,
session start/reuse/replace, endSession, reapIdleSessions -
child_process.spawn mocked via a small explicit test seam
(setSpawnForTesting), never invoking a real ffmpeg binary.
- server/test/socketGateway.test.ts, partyService.test.ts,
catalogService.test.ts: host-only gating + broadcast for
setBurnInSubtitle, advanceQueue's reset, toPublicState exposing the
new fields, getMediaSource.
- docs/architecture.md, README.md: design + an explicit "assumptions to
verify" note (the direct-source URL shape, first and foremost).
Verified: full typecheck+test+build on both projects (56/56 server
tests). No Emby server or ffmpeg binary is reachable from this dev
environment, so the actual command against a real PGS file is
unverified - flagged explicitly in the docs, same "TODO(verify)" spirit
already used elsewhere in this codebase for an Emby response shape that
can't be checked locally.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
116 lines
5.3 KiB
TypeScript
116 lines
5.3 KiB
TypeScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { PartyService } from '../src/services/PartyService';
|
|
|
|
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);
|
|
});
|
|
|
|
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 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';
|
|
|
|
PartyService.advanceQueue(party);
|
|
|
|
assert.equal(party.burnInSubtitleIndex, null);
|
|
assert.equal(party.burnInAudioIndex, null);
|
|
});
|
|
|
|
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('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');
|
|
});
|
|
});
|