Self-hosted ffmpeg pipeline for PGS (image) subtitle burn-in
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>
This commit is contained in:
parent
daab755ce9
commit
26da8fc5dc
22 changed files with 1071 additions and 35 deletions
20
README.md
20
README.md
|
|
@ -34,7 +34,13 @@ including why this replaced an earlier Emby-plugin attempt (still in
|
||||||
initials, on the player itself). If you're not the host, a small
|
initials, on the player itself). If you're not the host, a small
|
||||||
toolbar lets you ask the host to pause or resume — everyone sees the
|
toolbar lets you ask the host to pause or resume — everyone sees the
|
||||||
request, including the host, without needing chat.
|
request, including the host, without needing chat.
|
||||||
6. Music works differently, on purpose: pick a track (browsing Artist →
|
6. Subtitles render natively in the browser for any text-based track. A
|
||||||
|
Blu-ray/DVD-style image subtitle (PGS/VobSub/DVB) is different — the
|
||||||
|
host can pick one to have it burned into the video by this app's own
|
||||||
|
ffmpeg, shared live by everyone in the party (audio/quality selection
|
||||||
|
is fixed while that's active, and it restarts the stream for
|
||||||
|
everyone, so it's a host-only choice, like play/pause already is).
|
||||||
|
7. Music works differently, on purpose: pick a track (browsing Artist →
|
||||||
Album → Track) to start a listening party. There's no host for
|
Album → Track) to start a listening party. There's no host for
|
||||||
playback — anyone can add more tracks to the shared queue at any time,
|
playback — anyone can add more tracks to the shared queue at any time,
|
||||||
anyone can pause/resume, and skipping the current track needs a
|
anyone can pause/resume, and skipping the current track needs a
|
||||||
|
|
@ -79,7 +85,14 @@ every push and gates the Docker image publish on it passing — see
|
||||||
Dockerfile - this dev environment has no Docker - so treat the first real
|
Dockerfile - this dev environment has no Docker - so treat the first real
|
||||||
build as the thing to double-check, even though it mirrors exactly the
|
build as the thing to double-check, even though it mirrors exactly the
|
||||||
`npm run build` commands already confirmed working locally for both
|
`npm run build` commands already confirmed working locally for both
|
||||||
projects.
|
projects. Same caveat, doubled, for the self-hosted ffmpeg burn-in
|
||||||
|
pipeline (see docs/architecture.md): no Emby server *or* ffmpeg binary
|
||||||
|
is reachable here either, so while its pure logic (stream-index
|
||||||
|
conversion, session lifecycle) is unit-tested with `child_process.spawn`
|
||||||
|
mocked, the actual command against a real PGS file - and the exact
|
||||||
|
shape of Emby's direct-stream URL it depends on - has never run for
|
||||||
|
real. Treat the first real attempt at a Blu-ray-style subtitle as the
|
||||||
|
thing to double-check here too.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
|
|
@ -219,6 +232,9 @@ Then put it behind your existing reverse proxy like everything else
|
||||||
- [x] Music player as a persistent bottom bar instead of a page — browse
|
- [x] Music player as a persistent bottom bar instead of a page — browse
|
||||||
and queue tracks while listening; starting a video party always
|
and queue tracks while listening; starting a video party always
|
||||||
leaves the listen party first — see docs/architecture.md
|
leaves the listen party first — see docs/architecture.md
|
||||||
|
- [x] Self-hosted ffmpeg burn-in for PGS/image subtitles (Emby's own
|
||||||
|
transcode can't render these as text) — one shared stream per
|
||||||
|
party, host-driven — see docs/architecture.md
|
||||||
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
||||||
- [ ] Tune transcoding target / support direct play when possible
|
- [ ] Tune transcoding target / support direct play when possible
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -132,3 +132,13 @@ export function audioStreamUrl(itemId: string): string {
|
||||||
export function subtitleUrl(itemId: string, subtitleIndex: number): string {
|
export function subtitleUrl(itemId: string, subtitleIndex: number): string {
|
||||||
return `/api/catalog/subtitle/${itemId}/${subtitleIndex}`;
|
return `/api/catalog/subtitle/${itemId}/${subtitleIndex}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The party's own self-hosted-ffmpeg burn-in stream (server/src/services/
|
||||||
|
* TranscodeService.ts) - for a PGS/image subtitle, which a native
|
||||||
|
* <track> can't render (see subtitleUrl above). Keyed by partyId, not
|
||||||
|
* itemId/subtitleIndex: the party itself carries which combination is
|
||||||
|
* currently burned in (Party.burnInSubtitleIndex), and it's shared by
|
||||||
|
* every member - see Player.ts's onBurnInChanged. */
|
||||||
|
export function burnInStreamUrl(partyId: string): string {
|
||||||
|
return `/api/catalog/burn/${partyId}/master.m3u8`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
// No join codes: joining happens straight from the home page's live list
|
// No join codes: joining happens straight from the home page's live list
|
||||||
// (connectLobby below) by party id - see docs/architecture.md.
|
// (connectLobby below) by party id - see docs/architecture.md.
|
||||||
import { getDeviceId } from '../deviceId';
|
import { getDeviceId } from '../deviceId';
|
||||||
import type { ItemChangedMsg, LobbyPartyDto, MusicPublicState, MusicTrackInput, PartyPublicState, PauseRequestedMsg, ResumeRequestedMsg, SocketMember } from '../types';
|
import type { BurnInChangedMsg, ItemChangedMsg, LobbyPartyDto, MusicPublicState, MusicTrackInput, PartyPublicState, PauseRequestedMsg, ResumeRequestedMsg, SocketMember } from '../types';
|
||||||
|
|
||||||
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
|
|
@ -85,6 +85,9 @@ export interface PartyConnectionCallbacks {
|
||||||
* calls confirmDeviceTakeover() or gives up (e.g. navigates away). See
|
* calls confirmDeviceTakeover() or gives up (e.g. navigates away). See
|
||||||
* SocketGateway's "join" handler. */
|
* SocketGateway's "join" handler. */
|
||||||
onDeviceConflict?: () => void;
|
onDeviceConflict?: () => void;
|
||||||
|
/** The host picked (or cleared) the party's shared burn-in stream - see
|
||||||
|
* setBurnInSubtitle below and Player.ts. */
|
||||||
|
onBurnInChanged?: (msg: BurnInChangedMsg) => void;
|
||||||
onError?: (message: string) => void;
|
onError?: (message: string) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +111,12 @@ export interface PartyConnection {
|
||||||
* disconnect their other device and take over here - call this from
|
* disconnect their other device and take over here - call this from
|
||||||
* onDeviceConflict's handler once the user says yes. */
|
* onDeviceConflict's handler once the user says yes. */
|
||||||
confirmDeviceTakeover(): void;
|
confirmDeviceTakeover(): void;
|
||||||
|
/** Host-only: picks the party's shared self-hosted-ffmpeg burn-in
|
||||||
|
* stream for a PGS/image subtitle (null clears it) - see
|
||||||
|
* TranscodeService and Player.ts's subtitle <select>. Not for a text
|
||||||
|
* subtitle - that stays the existing per-viewer client-side <track>,
|
||||||
|
* no socket call at all. */
|
||||||
|
setBurnInSubtitle(subtitleIndex: string | null, audioIndex?: string): void;
|
||||||
leave(): void;
|
leave(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +127,7 @@ export interface PartyConnection {
|
||||||
* alive via closures, never gets released.
|
* alive via closures, never gets released.
|
||||||
*/
|
*/
|
||||||
export function connectParty(partyId: string, callbacks: PartyConnectionCallbacks = {}): PartyConnection {
|
export function connectParty(partyId: string, callbacks: PartyConnectionCallbacks = {}): PartyConnection {
|
||||||
const { onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onResumeRequested, onQueueUpdated, onDeviceConflict, onError, onClose } = callbacks;
|
const { onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onResumeRequested, onQueueUpdated, onDeviceConflict, onBurnInChanged, onError, onClose } = callbacks;
|
||||||
const socket = io();
|
const socket = io();
|
||||||
const deviceId = getDeviceId();
|
const deviceId = getDeviceId();
|
||||||
|
|
||||||
|
|
@ -140,6 +149,7 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
|
||||||
if (onResumeRequested) socket.on('resumeRequested', (m) => onResumeRequested(m as ResumeRequestedMsg));
|
if (onResumeRequested) socket.on('resumeRequested', (m) => onResumeRequested(m as ResumeRequestedMsg));
|
||||||
if (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see musicBar/MusicBar.ts
|
if (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see musicBar/MusicBar.ts
|
||||||
if (onDeviceConflict) socket.on('deviceConflict', () => onDeviceConflict());
|
if (onDeviceConflict) socket.on('deviceConflict', () => onDeviceConflict());
|
||||||
|
if (onBurnInChanged) socket.on('burnInChanged', (m) => onBurnInChanged(m as BurnInChangedMsg));
|
||||||
if (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message));
|
if (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message));
|
||||||
if (onClose) socket.on('disconnect', () => onClose());
|
if (onClose) socket.on('disconnect', () => onClose());
|
||||||
|
|
||||||
|
|
@ -165,6 +175,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
|
||||||
confirmDeviceTakeover() {
|
confirmDeviceTakeover() {
|
||||||
socket.emit('join', { partyId, deviceId, force: true });
|
socket.emit('join', { partyId, deviceId, force: true });
|
||||||
},
|
},
|
||||||
|
setBurnInSubtitle(subtitleIndex, audioIndex) {
|
||||||
|
socket.emit('setBurnInSubtitle', { subtitleIndex, audioIndex });
|
||||||
|
},
|
||||||
leave() {
|
leave() {
|
||||||
socket.emit('leave');
|
socket.emit('leave');
|
||||||
socket.disconnect();
|
socket.disconnect();
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,12 @@ export interface PartyPublicState {
|
||||||
youAreHost: boolean;
|
youAreHost: boolean;
|
||||||
members: PublicMember[];
|
members: PublicMember[];
|
||||||
music: MusicPublicState | null;
|
music: MusicPublicState | null;
|
||||||
|
// Video only - the host's current pick for the party's shared burn-in
|
||||||
|
// stream (see server/src/services/TranscodeService.ts), null when the
|
||||||
|
// party is just using the normal per-viewer Emby-proxied stream. Both
|
||||||
|
// null or both set together - see SocketGateway's own comment.
|
||||||
|
burnInSubtitleIndex: string | null;
|
||||||
|
burnInAudioIndex: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The socket "members" broadcast's shape - smaller than
|
/** The socket "members" broadcast's shape - smaller than
|
||||||
|
|
@ -159,3 +165,12 @@ export interface ResumeRequestedMsg {
|
||||||
userId: string;
|
userId: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "burnInChanged" - see api/party.ts's setBurnInSubtitle/onBurnInChanged
|
||||||
|
* and PartyPublicState.burnInSubtitleIndex above (this is the live-push
|
||||||
|
* half; that's the on-load/rejoin half). Both null together clears the
|
||||||
|
* party's burn-in stream back to the normal per-viewer one. */
|
||||||
|
export interface BurnInChangedMsg {
|
||||||
|
subtitleIndex: string | null;
|
||||||
|
audioIndex: string | null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,18 @@
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settingsPanel select:disabled {
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .hint's own bottom margin (base.css) would stack with the panel's own
|
||||||
|
flex `gap` below it - zeroed here so it's spaced exactly like every
|
||||||
|
other row in the column, not a wider gap after this specific one. */
|
||||||
|
.burnInHint {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
.loadingNotice {
|
.loadingNotice {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
<label>Langue <select id="audioSelect"></select></label>
|
<label>Langue <select id="audioSelect"></select></label>
|
||||||
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
|
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
|
||||||
<label>Qualité <select id="qualitySelect"></select></label>
|
<label>Qualité <select id="qualitySelect"></select></label>
|
||||||
|
<p id="burnInHint" class="hint burnInHint hide">Flux incrusté actif pour toute la party - langue et qualité fixées tant qu'il l'est.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import '../../styles/presence.css';
|
import '../../styles/presence.css';
|
||||||
import './Player.css';
|
import './Player.css';
|
||||||
import html from './Player.html';
|
import html from './Player.html';
|
||||||
import { auth, getPlaybackOptions, streamUrl, subtitleUrl } from '../../api/embyApi';
|
import { auth, burnInStreamUrl, getPlaybackOptions, streamUrl, subtitleUrl } from '../../api/embyApi';
|
||||||
import { getParty, leaveParty, connectParty } from '../../api/party';
|
import { getParty, leaveParty, connectParty } from '../../api/party';
|
||||||
import type { PartyConnection } from '../../api/party';
|
import type { PartyConnection } from '../../api/party';
|
||||||
import { navigate } from '../../router';
|
import { navigate } from '../../router';
|
||||||
import type { CleanupFn, RouteParams } from '../../router';
|
import type { CleanupFn, RouteParams } from '../../router';
|
||||||
import { colorForName, escapeHtml, initialsFor } from '../../tiles';
|
import { colorForName, escapeHtml, initialsFor } from '../../tiles';
|
||||||
import type { ItemChangedMsg, PartyPublicState, PauseRequestedMsg, ResumeRequestedMsg, SocketMember } from '../../types';
|
import type { BurnInChangedMsg, ItemChangedMsg, PartyPublicState, PauseRequestedMsg, PlaybackOptionsDto, ResumeRequestedMsg, SocketMember } from '../../types';
|
||||||
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
|
import { joinMusicParty, leaveMusicParty } from '../../musicBar/MusicBar';
|
||||||
|
|
||||||
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
|
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
|
||||||
|
|
@ -143,6 +143,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
const audioSelect = root.querySelector('#audioSelect') as HTMLSelectElement;
|
const audioSelect = root.querySelector('#audioSelect') as HTMLSelectElement;
|
||||||
const subtitleSelect = root.querySelector('#subtitleSelect') as HTMLSelectElement;
|
const subtitleSelect = root.querySelector('#subtitleSelect') as HTMLSelectElement;
|
||||||
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
|
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
|
||||||
|
const burnInHint = root.querySelector('#burnInHint')!;
|
||||||
|
|
||||||
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
|
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
|
||||||
|
|
||||||
|
|
@ -173,6 +174,17 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
let pauseRequestCooldownUntil = 0;
|
let pauseRequestCooldownUntil = 0;
|
||||||
let resumeRequestCooldownUntil = 0;
|
let resumeRequestCooldownUntil = 0;
|
||||||
let requestBannerTimer: number | null = null;
|
let requestBannerTimer: number | null = null;
|
||||||
|
// The party's shared self-hosted-ffmpeg burn-in choice (see
|
||||||
|
// TranscodeService) - null means "no burn-in, everyone streams their
|
||||||
|
// own normal per-viewer Emby-proxied copy" - initialized from the
|
||||||
|
// party's own current state (party.burnIn*) so a late joiner picks it
|
||||||
|
// up immediately, not just from a live "burnInChanged" broadcast.
|
||||||
|
let burnInSubtitleIndex: string | null = party.burnInSubtitleIndex;
|
||||||
|
let burnInAudioIndex: string | null = party.burnInAudioIndex;
|
||||||
|
// Cached purely so becomeHost (host status can change live mid-session
|
||||||
|
// - see its own comment) can re-render the subtitle picker's host-only
|
||||||
|
// gating for burn-in options without a redundant playback-options fetch.
|
||||||
|
let lastPlaybackOptions: PlaybackOptionsDto | null = null;
|
||||||
|
|
||||||
function showError(message: string, detail?: unknown) {
|
function showError(message: string, detail?: unknown) {
|
||||||
loadingNotice.classList.add('hide');
|
loadingNotice.classList.add('hide');
|
||||||
|
|
@ -227,30 +239,67 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
|
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
|
||||||
if (!heartbeat) heartbeat = window.setInterval(sendLocalState, 3000);
|
if (!heartbeat) heartbeat = window.setInterval(sendLocalState, 3000);
|
||||||
|
|
||||||
|
// Burn-in (image) subtitle options are host-only (see
|
||||||
|
// renderSubtitleOptions) - becoming host mid-session must unlock
|
||||||
|
// them immediately, not just on this player's next full re-render.
|
||||||
|
renderSubtitleOptions();
|
||||||
|
|
||||||
if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte.");
|
if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte.");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTrackOptions(itemId: string) {
|
async function loadTrackOptions(itemId: string) {
|
||||||
try {
|
try {
|
||||||
const options = await getPlaybackOptions(itemId);
|
const options = await getPlaybackOptions(itemId);
|
||||||
|
lastPlaybackOptions = options;
|
||||||
audioSelect.innerHTML = options.audioTracks.length
|
audioSelect.innerHTML = options.audioTracks.length
|
||||||
? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
|
? options.audioTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
|
||||||
: '<option value="">Piste par défaut</option>';
|
: '<option value="">Piste par défaut</option>';
|
||||||
// isTextBased: false means this is an image/bitmap subtitle (PGS/
|
renderSubtitleOptions();
|
||||||
// VobSub/DVB, typically a Blu-ray/DVD rip) - rendered as a native
|
|
||||||
// <track> (see applySubtitleTrack), which needs real text; a
|
|
||||||
// bitmap has none for Emby to convert to WebVTT (proxySubtitle).
|
|
||||||
// Disabled rather than hidden, so it's clear the track exists and
|
|
||||||
// *why* it can't be picked, instead of silently missing from the
|
|
||||||
// list.
|
|
||||||
subtitleSelect.innerHTML = '<option value="">Aucun</option>' +
|
|
||||||
options.subtitleTracks.map((t) => `<option value="${t.index}"${t.isTextBased === false ? ' disabled' : ''}>${escapeHtml(t.label)}${t.isTextBased === false ? ' (image, non disponible)' : ''}</option>`).join('');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[watchparty player] playback-options failed', err);
|
console.error('[watchparty player] playback-options failed', err);
|
||||||
// Non-fatal: playback can still work with Emby's own defaults, just without a picker.
|
// Non-fatal: playback can still work with Emby's own defaults, just without a picker.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Re)builds the subtitle <select> from the last-fetched playback
|
||||||
|
* options, called both on a genuine item load and whenever burn-in/host
|
||||||
|
* state changes (becomeHost, onBurnInChanged) without a fresh fetch.
|
||||||
|
*
|
||||||
|
* isTextBased: false means this is an image/bitmap subtitle (PGS/
|
||||||
|
* VobSub/DVB, typically a Blu-ray/DVD rip) - a native <track> (see
|
||||||
|
* applySubtitleTrack) needs real text, which a bitmap has none of for
|
||||||
|
* Emby to convert to WebVTT. These now go through TranscodeService's
|
||||||
|
* own self-hosted ffmpeg burn-in instead - see docs/architecture.md's
|
||||||
|
* "Self-hosted ffmpeg" section - which is a party-*wide*, host-driven
|
||||||
|
* choice (like play/pause/next already are), not a per-viewer local
|
||||||
|
* one the way a text subtitle still is. So: host-only selectable
|
||||||
|
* (`data-burn-in` flags which options these are, for the change
|
||||||
|
* handler below), and picking one restarts the stream for everyone -
|
||||||
|
* the label says so instead of leaving it a surprise.
|
||||||
|
*/
|
||||||
|
function renderSubtitleOptions() {
|
||||||
|
if (!lastPlaybackOptions) return;
|
||||||
|
const previousValue = subtitleSelect.value;
|
||||||
|
subtitleSelect.innerHTML = '<option value="">Aucun</option>' +
|
||||||
|
lastPlaybackOptions.subtitleTracks.map((t) => {
|
||||||
|
if (t.isTextBased === false) {
|
||||||
|
const suffix = isHost ? ' (incrustation - redémarre la vidéo pour tous)' : ' (incrustation, hôte uniquement)';
|
||||||
|
return `<option value="${t.index}" data-burn-in="1"${isHost ? '' : ' disabled'}>${escapeHtml(t.label)}${suffix}</option>`;
|
||||||
|
}
|
||||||
|
return `<option value="${t.index}">${escapeHtml(t.label)}</option>`;
|
||||||
|
}).join('');
|
||||||
|
subtitleSelect.value = burnInSubtitleIndex || previousValue;
|
||||||
|
|
||||||
|
// While a burn-in stream is active, a follower has nothing meaningful
|
||||||
|
// left to pick here - the shared stream's audio+video bytes are
|
||||||
|
// already fixed either way, and their own text-subtitle/"Aucun"
|
||||||
|
// choice wouldn't change anything about it. Only the host, who can
|
||||||
|
// also change or clear the burn-in itself, keeps this enabled.
|
||||||
|
subtitleSelect.disabled = !!burnInSubtitleIndex && !isHost;
|
||||||
|
burnInHint.classList.toggle('hide', !burnInSubtitleIndex);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds/replaces the video's own <track> element for whichever subtitle
|
* Adds/replaces the video's own <track> element for whichever subtitle
|
||||||
* (if any) is currently selected - a native browser feature, entirely
|
* (if any) is currently selected - a native browser feature, entirely
|
||||||
|
|
@ -292,17 +341,28 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
hls = null;
|
hls = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-applied on every (re)load, not just when the subtitle picker
|
// A burn-in stream already has its subtitle composited into the
|
||||||
// itself changes: an audio/quality change reloads this same item
|
// video itself - a real <track> on top would be redundant at best.
|
||||||
// through here too (see reloadWithCurrentOptions), and the subtitle
|
// Re-applied on every (re)load otherwise, not just when the subtitle
|
||||||
// that was showing should survive that - subtitleSelect.value is
|
// picker itself changes: an audio/quality change reloads this same
|
||||||
// untouched in that case, so this just re-adds the same track. A
|
// item through here too (see reloadWithCurrentOptions), and the
|
||||||
|
// subtitle that was showing should survive that - subtitleSelect.value
|
||||||
|
// is untouched in that case, so this just re-adds the same track. A
|
||||||
// genuinely new item (onItemChanged/init) already reset the select
|
// genuinely new item (onItemChanged/init) already reset the select
|
||||||
// to "Aucun" before this runs (see loadTrackOptions), so this
|
// to "Aucun" before this runs (see loadTrackOptions), so this
|
||||||
// correctly clears any leftover track from whatever played before.
|
// correctly clears any leftover track from whatever played before.
|
||||||
applySubtitleTrack(subtitleSelect.value);
|
const usingBurnIn = !!burnInSubtitleIndex;
|
||||||
|
applySubtitleTrack(usingBurnIn ? '' : subtitleSelect.value);
|
||||||
|
// The burn-in stream is one fixed ffmpeg encode for the whole party -
|
||||||
|
// neither of these picks apply to it (see renderSubtitleOptions'
|
||||||
|
// burnInHint). Re-enabled automatically once burn-in is cleared,
|
||||||
|
// since this runs on every load/reload.
|
||||||
|
audioSelect.disabled = usingBurnIn;
|
||||||
|
qualitySelect.disabled = usingBurnIn;
|
||||||
|
|
||||||
const src = streamUrl(itemId, {
|
const src = usingBurnIn
|
||||||
|
? burnInStreamUrl(partyId)
|
||||||
|
: streamUrl(itemId, {
|
||||||
AudioStreamIndex: audioSelect.value,
|
AudioStreamIndex: audioSelect.value,
|
||||||
MaxStreamingBitrate: qualitySelect.value
|
MaxStreamingBitrate: qualitySelect.value
|
||||||
});
|
});
|
||||||
|
|
@ -353,12 +413,26 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Just swaps the <track> - no reload, no re-buffering, unlike audio/
|
// A text pick (or "Aucun") just swaps the <track> - no reload, no
|
||||||
// quality above. This is the whole point of rendering subtitles
|
// re-buffering, the whole point of rendering those natively instead of
|
||||||
// natively instead of asking Emby to burn them into the video.
|
// asking Emby to burn them in. An image (burn-in) pick, or clearing an
|
||||||
|
// already-active burn-in by picking a text subtitle/"Aucun" instead,
|
||||||
|
// goes through the host-only "setBurnInSubtitle" socket call and does
|
||||||
|
// NOT reload here directly - every member (including this host) reacts
|
||||||
|
// to the resulting "burnInChanged" broadcast the same way, see
|
||||||
|
// onBurnInChanged below. Same reasoning as onItemChanged/
|
||||||
|
// applyRemoteState never applying anything optimistically before the
|
||||||
|
// server's own broadcast confirms it.
|
||||||
subtitleSelect.addEventListener('change', () => {
|
subtitleSelect.addEventListener('change', () => {
|
||||||
settingsPanel.classList.add('hide');
|
settingsPanel.classList.add('hide');
|
||||||
|
const isBurnInPick = subtitleSelect.selectedOptions[0]?.dataset.burnIn === '1';
|
||||||
|
if (isBurnInPick) {
|
||||||
|
conn?.setBurnInSubtitle(subtitleSelect.value, audioSelect.value || undefined);
|
||||||
|
} else if (burnInSubtitleIndex) {
|
||||||
|
conn?.setBurnInSubtitle(null);
|
||||||
|
} else {
|
||||||
applySubtitleTrack(subtitleSelect.value);
|
applySubtitleTrack(subtitleSelect.value);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
(root.querySelector('#playerErrorRetry') as HTMLButtonElement).addEventListener('click', () => {
|
(root.querySelector('#playerErrorRetry') as HTMLButtonElement).addEventListener('click', () => {
|
||||||
|
|
@ -369,10 +443,31 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
itemTitleEl.textContent = msg.itemName || '';
|
itemTitleEl.textContent = msg.itemName || '';
|
||||||
hasNextQueued = !!msg.hasNext;
|
hasNextQueued = !!msg.hasNext;
|
||||||
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
||||||
|
// A new item's own stream indices have nothing to do with the old
|
||||||
|
// one's - PartyService.advanceQueue already resets this server-side
|
||||||
|
// too (see its own comment); this just keeps this client in sync
|
||||||
|
// without waiting on a separate "burnInChanged" for the same event.
|
||||||
|
burnInSubtitleIndex = null;
|
||||||
|
burnInAudioIndex = null;
|
||||||
await loadTrackOptions(msg.itemId);
|
await loadTrackOptions(msg.itemId);
|
||||||
loadVideo(msg.itemId, 0, true);
|
loadVideo(msg.itemId, 0, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The host picked or cleared the party's shared burn-in stream - see
|
||||||
|
* the subtitleSelect change handler above for why every member
|
||||||
|
* (host included) only ever reacts to this broadcast rather than
|
||||||
|
* applying anything optimistically first. Reloads for every member -
|
||||||
|
* there's no cheaper local-only path since the actual video bytes
|
||||||
|
* differ now. A fresh page load/rejoin instead reads the party's
|
||||||
|
* already-current burnIn* fields directly - see the outer variable
|
||||||
|
* declarations above. */
|
||||||
|
function onBurnInChanged(msg: BurnInChangedMsg) {
|
||||||
|
burnInSubtitleIndex = msg.subtitleIndex;
|
||||||
|
burnInAudioIndex = msg.audioIndex;
|
||||||
|
renderSubtitleOptions();
|
||||||
|
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
|
||||||
|
}
|
||||||
|
|
||||||
// Presence overlay: one semi-transparent circle per member, initials
|
// Presence overlay: one semi-transparent circle per member, initials
|
||||||
// only (no photos - Emby accounts don't reliably have one). Purely
|
// only (no photos - Emby accounts don't reliably have one). Purely
|
||||||
// presence now - asking the host for something is the requestToolbar's
|
// presence now - asking the host for something is the requestToolbar's
|
||||||
|
|
@ -446,6 +541,12 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
followerNotice.classList.toggle('hide', party.youAreHost);
|
followerNotice.classList.toggle('hide', party.youAreHost);
|
||||||
requestToolbar.classList.toggle('hide', party.youAreHost);
|
requestToolbar.classList.toggle('hide', party.youAreHost);
|
||||||
|
|
||||||
|
// Before loadTrackOptions: renderSubtitleOptions' host-only gating
|
||||||
|
// for burn-in options reads `isHost`, which becomeHost is what sets -
|
||||||
|
// otherwise the actual host's very first render would show every
|
||||||
|
// image-subtitle option as disabled, as if they were a follower.
|
||||||
|
if (party.youAreHost) becomeHost(false);
|
||||||
|
|
||||||
await loadTrackOptions(party.itemId);
|
await loadTrackOptions(party.itemId);
|
||||||
loadVideo(party.itemId, 0, false);
|
loadVideo(party.itemId, 0, false);
|
||||||
|
|
||||||
|
|
@ -456,6 +557,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
onHostChanged: () => becomeHost(true),
|
onHostChanged: () => becomeHost(true),
|
||||||
onPauseRequested,
|
onPauseRequested,
|
||||||
onResumeRequested,
|
onResumeRequested,
|
||||||
|
onBurnInChanged,
|
||||||
onDeviceConflict: () => {
|
onDeviceConflict: () => {
|
||||||
// This socket is still connected but inert (see SocketGateway's
|
// This socket is still connected but inert (see SocketGateway's
|
||||||
// "join" handler / api/party.ts) - a confirm() is fine here even
|
// "join" handler / api/party.ts) - a confirm() is fine here even
|
||||||
|
|
@ -471,8 +573,6 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
onError: (message) => alert(message),
|
onError: (message) => alert(message),
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (party.youAreHost) becomeHost(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nextBtn.addEventListener('click', () => { conn?.next(); });
|
nextBtn.addEventListener('click', () => { conn?.next(); });
|
||||||
|
|
|
||||||
|
|
@ -1636,6 +1636,113 @@ a dedicated `PartyService.join()` idempotency test, and confirmed
|
||||||
`deviceId.ts`'s `localStorage` persistence survives a real page reload
|
`deviceId.ts`'s `localStorage` persistence survives a real page reload
|
||||||
against the compiled bundle in a real browser.
|
against the compiled bundle in a real browser.
|
||||||
|
|
||||||
|
## Self-hosted ffmpeg: burning in PGS (image) subtitles ourselves
|
||||||
|
|
||||||
|
Asked for directly, right after the client-side `<track>` redesign
|
||||||
|
("Player track selection" above) 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. `getPlaybackOptions`
|
||||||
|
already flagged these `isTextBased: false`; until now the picker just
|
||||||
|
left them disabled.
|
||||||
|
|
||||||
|
**Why running our own ffmpeg can plausibly succeed where Emby's kept
|
||||||
|
failing** (see "Player track selection" for the whole saga): Emby's own
|
||||||
|
burn-in goes through libass (the `subtitles` filter), which is text-only
|
||||||
|
and needs fontconfig/real fonts - the exact thing that was missing, and
|
||||||
|
even once fixed, apparently still not enough (a different,
|
||||||
|
never-fully-diagnosed "Error starting ffmpeg" on an otherwise-unremarkable
|
||||||
|
file). A PGS stream decodes like a second video track; ffmpeg's
|
||||||
|
`overlay` filter (`[0:v][0:s:N]overlay`) composites it directly - no
|
||||||
|
text rendering, no libass, no fontconfig anywhere in this path. The
|
||||||
|
Dockerfile installs plain `ffmpeg` (`apk add ffmpeg`) - no font packages
|
||||||
|
at all, unlike what Emby's own text burn-in required.
|
||||||
|
|
||||||
|
**One shared stream per party, not per viewer** -
|
||||||
|
`server/src/services/TranscodeService.ts` keys its session map by
|
||||||
|
`partyId` alone. This mirrors "host drives, everyone follows" (see
|
||||||
|
"Realtime transport" above) rather than inventing a second model:
|
||||||
|
picking a burn-in subtitle is a host-only, party-*wide* choice
|
||||||
|
(`SocketGateway`'s `setBurnInSubtitle` handler, broadcasting
|
||||||
|
`burnInChanged` - byte-for-byte the same request/broadcast/react shape
|
||||||
|
`next`/`itemChanged` already use), not a per-viewer local one the way a
|
||||||
|
*text* subtitle pick still is. `Party` gained `burnInSubtitleIndex`/
|
||||||
|
`burnInAudioIndex` (and `PartyPublicState` exposes them too) so a
|
||||||
|
follower joining mid-party, or a page reload, picks up the party's
|
||||||
|
current choice from a plain `getParty()` - same reason `itemId`/
|
||||||
|
`queueIndex` already work that way.
|
||||||
|
|
||||||
|
**Source**: ffmpeg is pointed straight at Emby's own *direct* (static,
|
||||||
|
untranscoded) file endpoint - `/Videos/{itemId}/stream.{container}
|
||||||
|
?Static=true&MediaSourceId={id}` - authenticated the same way
|
||||||
|
`EmbyClient.fetch` already does (`X-Emby-Token` via `-headers`, never
|
||||||
|
`api_key` in the URL). This is the one piece flagged **TODO(verify)**:
|
||||||
|
there's no Emby server reachable from the environment this was built in,
|
||||||
|
so the exact shape is unconfirmed against this app's own real server -
|
||||||
|
see `TranscodeService.ts`'s `buildSourceUrl`. If it's wrong, it's
|
||||||
|
isolated to that one function.
|
||||||
|
|
||||||
|
**Stream indices**: Emby's `MediaStreams[].Index` is one flat, absolute
|
||||||
|
number across video+audio+subtitle together (the "stream 6/7/8" this
|
||||||
|
app's own picker already surfaces) - ffmpeg has no notion of that at
|
||||||
|
all, only type-relative specifiers (`0:s:N`/`0:a:N`, counting only among
|
||||||
|
streams of that type). `absoluteToRelativeIndex` does the conversion -
|
||||||
|
pure array math, fully unit-tested with no ffmpeg or Emby involved (see
|
||||||
|
`transcodeService.test.ts`).
|
||||||
|
|
||||||
|
**Output**: real, progressively-written HLS (`-f hls`, a growing
|
||||||
|
`master.m3u8` + segments) to a temp directory, served by two new routes
|
||||||
|
(`GET /api/catalog/burn/:partyId/master.m3u8` and `.../:segment`) that
|
||||||
|
poll briefly for the next file to exist rather than failing immediately -
|
||||||
|
ffmpeg is still writing while a viewer's hls.js is asking for it.
|
||||||
|
Deliberately not "transcode the whole file first, then serve it": for a
|
||||||
|
two-hour movie that would mean a wait proportional to its length before
|
||||||
|
anyone could press play at all, which defeats the point of a *watch
|
||||||
|
party*. This also means the client needs no new player logic at all -
|
||||||
|
same hls.js, same manifest/segment polling, just pointed at a different
|
||||||
|
server path (`api/embyApi.ts`'s `burnInStreamUrl`) - see
|
||||||
|
`views/player/Player.ts`'s `loadVideo`.
|
||||||
|
|
||||||
|
**Lifecycle**: a session tracks `{process, outputDir, lastAccessUtc}`;
|
||||||
|
every request refreshes it. `PartyService.leave`'s destroy branch and
|
||||||
|
`.advanceQueue` (a new item's stream indices mean nothing on the old
|
||||||
|
one) both end a party's session immediately; `removeIdleParties`'s own
|
||||||
|
idle sweep and a shorter, dedicated `TranscodeService.reapIdleSessions`
|
||||||
|
interval in `index.ts` (30min idle, checked every 5min - shorter than
|
||||||
|
parties themselves, since this holds a real OS process + disk, not just
|
||||||
|
an in-memory object) are the backstop for anything that doesn't end
|
||||||
|
cleanly. ffmpeg's stderr is captured into a rolling buffer and logged
|
||||||
|
through a new `Logger.transcode()` (same reasoning as `Logger.emby` -
|
||||||
|
see "Server-side logging" above) so a failure is visible in `docker
|
||||||
|
logs` immediately instead of a stuck spinner in the browser.
|
||||||
|
|
||||||
|
**Player UI**: the subtitle `<select>`'s image-track options go from
|
||||||
|
unconditionally disabled to *host-only* selectable (`Player.ts`'s
|
||||||
|
`renderSubtitleOptions`, gated on the same `isHost` that already gates
|
||||||
|
`nextBtn`/the settings menu), labeled to say picking one restarts the
|
||||||
|
stream for everyone. While a burn-in stream is active, the audio/quality
|
||||||
|
selects are disabled (the burn-in is one fixed encode) and a follower's
|
||||||
|
subtitle select is disabled too, since their own text-subtitle/"Aucun"
|
||||||
|
choice would no longer do anything - only the host, who can also change
|
||||||
|
or clear it, keeps it interactive. Every member (host included) applies
|
||||||
|
a burn-in change purely by reacting to the resulting `burnInChanged`
|
||||||
|
broadcast (`onBurnInChanged`, reloading `loadVideo`) - never
|
||||||
|
optimistically, same discipline `applyRemoteState`/`onItemChanged`
|
||||||
|
already use for every other party-wide state change.
|
||||||
|
|
||||||
|
Verified locally to the extent this dev environment allows (no Emby
|
||||||
|
server, no ffmpeg binary reachable here either): full typecheck/test/
|
||||||
|
build on both projects; `absoluteToRelativeIndex`, the exact ffmpeg
|
||||||
|
argument list, per-party session start/reuse/replace, and the idle
|
||||||
|
reaper are all unit-tested with `child_process.spawn` mocked (see
|
||||||
|
`TranscodeService.setSpawnForTesting` - a deliberate, explicit test seam
|
||||||
|
rather than relying on module-mock timing); `SocketGateway` tests cover
|
||||||
|
`setBurnInSubtitle`'s host-only gating and its `burnInChanged` broadcast.
|
||||||
|
The one thing that can't be proven here - an actual PGS file burning in
|
||||||
|
and playing against the real server - waits for a real try, the same
|
||||||
|
way the whole subtitle saga above was ultimately diagnosed from the
|
||||||
|
user's own pasted container logs, not local testing.
|
||||||
|
|
||||||
## A real server crash: one userId, two connections, one map slot
|
## A real server crash: one userId, two connections, one map slot
|
||||||
|
|
||||||
Reported directly from the running container's own crash output, not
|
Reported directly from the running container's own crash output, not
|
||||||
|
|
@ -1724,3 +1831,10 @@ regardless of how - or whether - that got resolved.
|
||||||
- not asked for, and would need its own protocol addition
|
- not asked for, and would need its own protocol addition
|
||||||
(`MusicQueueService` has no notion of "jump to position X" today, only
|
(`MusicQueueService` has no notion of "jump to position X" today, only
|
||||||
play/pause and advance).
|
play/pause and advance).
|
||||||
|
- The self-hosted burn-in stream (see "Self-hosted ffmpeg" above)
|
||||||
|
transcodes forward from the start in real time - seeking far ahead of
|
||||||
|
what ffmpeg has produced so far will stall until it catches up; there's
|
||||||
|
no "restart ffmpeg at the seek target" logic yet, unlike Emby's own
|
||||||
|
transcode for every other stream. Its direct-source URL is also the
|
||||||
|
one Emby-shaped assumption in this whole app that's never been
|
||||||
|
confirmed against a real server.
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,14 @@ RUN cd client && npm run build
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# ffmpeg - TranscodeService spawns it directly (see server/src/services/
|
||||||
|
# TranscodeService.ts) to burn PGS/image subtitles into video ourselves,
|
||||||
|
# the one gap Emby's own transcode + this app's client-side <track>
|
||||||
|
# rendering can't close between them. Unlike Emby's own text-subtitle
|
||||||
|
# burn-in, this needs no fontconfig/font packages at all - a PGS overlay
|
||||||
|
# is bitmap compositing, not text rendering.
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY server/package.json server/package-lock.json* ./server/
|
COPY server/package.json server/package-lock.json* ./server/
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"typecheck": "tsc -p tsconfig.test.json",
|
"typecheck": "tsc -p tsconfig.test.json",
|
||||||
"test": "tsx --test test/catalogService.test.ts test/embyClient.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/socketGateway.test.ts"
|
"test": "tsx --test test/catalogService.test.ts test/embyClient.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|
|
||||||
|
|
@ -54,4 +54,15 @@ export class Logger {
|
||||||
static emby(message: string): void {
|
static emby(message: string): void {
|
||||||
console.log(`[watchparty:emby] ${new Date().toISOString()} ${message}`);
|
console.log(`[watchparty:emby] ${new Date().toISOString()} ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TranscodeService's own ffmpeg processes (start/end, and any stderr
|
||||||
|
* captured from a process that exits before producing its first
|
||||||
|
* segment) - see docs/architecture.md's "Self-hosted ffmpeg" section.
|
||||||
|
* Same reasoning as `emby` above: a failure needs to be visible in
|
||||||
|
* `docker logs` immediately, not just a stuck spinner in the browser.
|
||||||
|
*/
|
||||||
|
static transcode(message: string): void {
|
||||||
|
console.log(`[watchparty:transcode] ${new Date().toISOString()} ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthServ
|
||||||
import { CatalogService, CatalogFetchError } from './services/CatalogService';
|
import { CatalogService, CatalogFetchError } from './services/CatalogService';
|
||||||
import { PartyService } from './services/PartyService';
|
import { PartyService } from './services/PartyService';
|
||||||
import { MusicQueueService } from './services/MusicQueueService';
|
import { MusicQueueService } from './services/MusicQueueService';
|
||||||
|
import { TranscodeService } from './services/TranscodeService';
|
||||||
import { SocketGateway } from './realtime/SocketGateway';
|
import { SocketGateway } from './realtime/SocketGateway';
|
||||||
import type { ClientToServerEvents, MediaType, QueueItem, ServerToClientEvents } from './types';
|
import type { ClientToServerEvents, MediaType, QueueItem, ServerToClientEvents } from './types';
|
||||||
|
|
||||||
|
|
@ -190,6 +191,40 @@ app.get('/api/catalog/subtitle/:itemId/:subtitleIndex', asyncRoute(async (req, r
|
||||||
await CatalogService.proxySubtitle(req, res, session, param(req, 'itemId'), param(req, 'subtitleIndex'));
|
await CatalogService.proxySubtitle(req, res, session, param(req, 'itemId'), param(req, 'subtitleIndex'));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The party's own self-hosted-ffmpeg burn-in stream (PGS/image subtitles
|
||||||
|
// only - see TranscodeService and Party.burnInSubtitleIndex's own
|
||||||
|
// comment); every other party keeps streaming through
|
||||||
|
// /api/catalog/stream above, completely untouched. Keyed by partyId, not
|
||||||
|
// itemId - the party object itself is the source of truth for which
|
||||||
|
// audio/subtitle combination is currently burned in, so there's nothing
|
||||||
|
// else a client could usefully put in this URL. requireSession's JSON
|
||||||
|
// error doesn't fit here either, same reasoning as the routes above.
|
||||||
|
app.get('/api/catalog/burn/:partyId/master.m3u8', asyncRoute(async (req, res) => {
|
||||||
|
const session = AuthService.sessionFromRequest(req);
|
||||||
|
if (!session) { res.status(401).end(); return; }
|
||||||
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
|
if (!party || !party.members.has(session.userId)) {
|
||||||
|
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!party.burnInSubtitleIndex) {
|
||||||
|
res.status(409).json({ error: "Cette watch party n'a pas de flux incrusté actif." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await TranscodeService.serveMasterPlaylist(req, res, party, session);
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/api/catalog/burn/:partyId/:segment', asyncRoute(async (req, res) => {
|
||||||
|
const session = AuthService.sessionFromRequest(req);
|
||||||
|
if (!session) { res.status(401).end(); return; }
|
||||||
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
|
if (!party || !party.members.has(session.userId)) {
|
||||||
|
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await TranscodeService.serveSegment(req, res, party, param(req, 'segment'));
|
||||||
|
}));
|
||||||
|
|
||||||
// --- Parties ---------------------------------------------------------------
|
// --- Parties ---------------------------------------------------------------
|
||||||
// No REST "list parties" endpoint: the home page's "watch parties en cours"
|
// No REST "list parties" endpoint: the home page's "watch parties en cours"
|
||||||
// list is live, over Socket.IO's "lobby" room (SocketGateway) - see
|
// list is live, over Socket.IO's "lobby" room (SocketGateway) - see
|
||||||
|
|
@ -347,6 +382,11 @@ SocketGateway.attach(io, (h) => AuthService.parseCookies(h), (id) => AuthService
|
||||||
MusicQueueService.attachIo(io, () => SocketGateway.broadcastLobby(io));
|
MusicQueueService.attachIo(io, () => SocketGateway.broadcastLobby(io));
|
||||||
|
|
||||||
setInterval(() => PartyService.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
|
setInterval(() => PartyService.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
|
||||||
|
// Shorter idle window than parties themselves: a burn-in session holds a
|
||||||
|
// real ffmpeg process + disk, not just an in-memory object, so a paused/
|
||||||
|
// abandoned one is worth reclaiming sooner. Mostly a backstop - the
|
||||||
|
// normal path is PartyService.leave's destroy branch ending it immediately.
|
||||||
|
setInterval(() => TranscodeService.reapIdleSessions(30 * 60 * 1000), 5 * 60 * 1000);
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
|
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,14 @@ import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } fr
|
||||||
* "playstate" (server -> client, to everyone in the party but the sender)
|
* "playstate" (server -> client, to everyone in the party but the sender)
|
||||||
* "next" (host only - advance the queue)
|
* "next" (host only - advance the queue)
|
||||||
* "itemChanged" (server -> client)
|
* "itemChanged" (server -> client)
|
||||||
|
* "setBurnInSubtitle" {subtitleIndex, audioIndex?} (host only - picks
|
||||||
|
* or clears the party's *shared* self-hosted-ffmpeg burn-in stream,
|
||||||
|
* for a PGS/image subtitle a native <track> can't render - see
|
||||||
|
* TranscodeService. A text subtitle never goes through here - that
|
||||||
|
* stays the existing per-viewer client-side <track>.)
|
||||||
|
* "burnInChanged" (server -> client)
|
||||||
*
|
*
|
||||||
|
|
||||||
* -- mediaType: 'music' parties (see MusicQueueService for the model) --
|
* -- mediaType: 'music' parties (see MusicQueueService for the model) --
|
||||||
* "voteSkip" (anyone - see MusicQueueService for the threshold)
|
* "voteSkip" (anyone - see MusicQueueService for the threshold)
|
||||||
* "musicPlaystate" {isPaused} (anyone - no host check, unlike video's "playstate")
|
* "musicPlaystate" {isPaused} (anyone - no host check, unlike video's "playstate")
|
||||||
|
|
@ -183,6 +190,23 @@ class SocketGatewayClass {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Host-only, video-only: picks (or clears) the party's *shared*
|
||||||
|
// burn-in stream - see TranscodeService and Party.burnInSubtitleIndex's
|
||||||
|
// own comment. Deliberately doesn't touch TranscodeService directly
|
||||||
|
// here - this only updates party state and broadcasts it, the same
|
||||||
|
// way "next" only updates queueIndex and broadcasts "itemChanged"
|
||||||
|
// without reaching into CatalogService itself. The actual ffmpeg
|
||||||
|
// session starts/restarts lazily, whenever a client's <video> next
|
||||||
|
// requests /api/catalog/burn/:partyId/master.m3u8 - see index.ts.
|
||||||
|
socket.on('setBurnInSubtitle', this.safe((msg: { subtitleIndex: string | null; audioIndex?: string }) => {
|
||||||
|
if (!party || party.mediaType !== 'video' || userId !== party.hostUserId) return;
|
||||||
|
party.burnInSubtitleIndex = msg?.subtitleIndex || null;
|
||||||
|
party.burnInAudioIndex = party.burnInSubtitleIndex ? (msg?.audioIndex || null) : null;
|
||||||
|
party.lastActivityUtc = Date.now();
|
||||||
|
Logger.party(`burnInChanged partyId=${party.id} subtitleIndex=${party.burnInSubtitleIndex ?? '(none)'} audioIndex=${party.burnInAudioIndex ?? '(default)'}`);
|
||||||
|
io.to(party.id).emit('burnInChanged', { subtitleIndex: party.burnInSubtitleIndex, audioIndex: party.burnInAudioIndex });
|
||||||
|
}));
|
||||||
|
|
||||||
// Majority of currently CONNECTED members, not everyone who's ever
|
// Majority of currently CONNECTED members, not everyone who's ever
|
||||||
// joined - see MusicQueueService.
|
// joined - see MusicQueueService.
|
||||||
socket.on('voteSkip', this.safe(() => {
|
socket.on('voteSkip', this.safe(() => {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ interface EmbyItemsResponse {
|
||||||
interface PlaybackInfoResponse {
|
interface PlaybackInfoResponse {
|
||||||
MediaSources?: Array<{
|
MediaSources?: Array<{
|
||||||
Id?: string;
|
Id?: string;
|
||||||
|
Container?: string;
|
||||||
TranscodingUrl?: string;
|
TranscodingUrl?: string;
|
||||||
MediaStreams?: Array<{ Type: string; Index: number; DisplayTitle?: string; Language?: string; Codec?: string }>;
|
MediaStreams?: Array<{ Type: string; Index: number; DisplayTitle?: string; Language?: string; Codec?: string }>;
|
||||||
}>;
|
}>;
|
||||||
|
|
@ -198,6 +199,27 @@ class CatalogServiceClass {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The negotiated MediaSource's id/container/streams - used by
|
||||||
|
* TranscodeService to build its own ffmpeg source URL and translate
|
||||||
|
* Emby's absolute stream indices into ffmpeg's relative ones. A
|
||||||
|
* separate method from proxyStream/proxySubtitle (which each do their
|
||||||
|
* own PlaybackInfo fetch already, for reasons specific to what they
|
||||||
|
* need back) rather than a forced consolidation - this is the one
|
||||||
|
* caller that actually needs Container + the full MediaStreams array
|
||||||
|
* together, and unlike proxyStream this hard-fails (throws) when
|
||||||
|
* there's no usable source rather than falling back to a hand-built
|
||||||
|
* URL, since there's no equivalent fallback for a raw ffmpeg input.
|
||||||
|
*/
|
||||||
|
async getMediaSource(session: EmbySession, itemId: string): Promise<{ id: string; container: string; streams: Array<{ Type: string; Index: number }> }> {
|
||||||
|
const info = await this.fetchJson<PlaybackInfoResponse>(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session, 'Impossible de préparer la lecture de ce titre.');
|
||||||
|
const source = (info.MediaSources || [])[0];
|
||||||
|
if (!source?.Id) {
|
||||||
|
throw new CatalogFetchError('Aucune source de lecture disponible pour ce titre.', 502);
|
||||||
|
}
|
||||||
|
return { id: source.Id, container: source.Container || 'mkv', streams: source.MediaStreams || [] };
|
||||||
|
}
|
||||||
|
|
||||||
// --- Direct proxying (images/video/audio) - these need the raw
|
// --- Direct proxying (images/video/audio) - these need the raw
|
||||||
// Express req/res, unlike everything above which just returns JSON. ---
|
// Express req/res, unlike everything above which just returns JSON. ---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { Logger } from '../Logger';
|
import { Logger } from '../Logger';
|
||||||
import { MusicQueueService } from './MusicQueueService';
|
import { MusicQueueService } from './MusicQueueService';
|
||||||
|
import { TranscodeService } from './TranscodeService';
|
||||||
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
|
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,7 +59,9 @@ class PartyServiceClass {
|
||||||
hostUserId,
|
hostUserId,
|
||||||
createdUtc: Date.now(),
|
createdUtc: Date.now(),
|
||||||
lastActivityUtc: Date.now(),
|
lastActivityUtc: Date.now(),
|
||||||
members: new Map()
|
members: new Map(),
|
||||||
|
burnInSubtitleIndex: null,
|
||||||
|
burnInAudioIndex: null
|
||||||
};
|
};
|
||||||
|
|
||||||
party.members.set(hostUserId, this.newMember(hostUserId, hostUserName));
|
party.members.set(hostUserId, this.newMember(hostUserId, hostUserName));
|
||||||
|
|
@ -124,6 +127,7 @@ class PartyServiceClass {
|
||||||
if (destroyed) {
|
if (destroyed) {
|
||||||
this.partiesById.delete(party.id);
|
this.partiesById.delete(party.id);
|
||||||
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
|
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
|
||||||
|
TranscodeService.endSession(party.id); // no-op if this party never had a burn-in stream running
|
||||||
Logger.party(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
|
Logger.party(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
|
||||||
} else if (wasHost && newHostUserId != null) {
|
} else if (wasHost && newHostUserId != null) {
|
||||||
const newHost = party.members.get(newHostUserId)!;
|
const newHost = party.members.get(newHostUserId)!;
|
||||||
|
|
@ -155,6 +159,16 @@ class PartyServiceClass {
|
||||||
member.isPaused = false;
|
member.isPaused = false;
|
||||||
member.lastUpdatedUtc = Date.now();
|
member.lastUpdatedUtc = Date.now();
|
||||||
}
|
}
|
||||||
|
// A new item's own MediaStreams indices have nothing to do with the
|
||||||
|
// old one's - carrying a burn-in pick across would burn in whatever
|
||||||
|
// stream happens to share that index on the new item, not the same
|
||||||
|
// subtitle. TranscodeService.endSession also releases the old item's
|
||||||
|
// now-irrelevant ffmpeg process/disk usage immediately rather than
|
||||||
|
// waiting for the idle reaper - see index.ts's own comment on why
|
||||||
|
// that's a backstop, not the normal path.
|
||||||
|
party.burnInSubtitleIndex = null;
|
||||||
|
party.burnInAudioIndex = null;
|
||||||
|
TranscodeService.endSession(party.id);
|
||||||
party.lastActivityUtc = Date.now();
|
party.lastActivityUtc = Date.now();
|
||||||
Logger.party(`advanced partyId=${party.id} to item="${next.itemName}" (${party.queueIndex + 1}/${party.queue.length})`);
|
Logger.party(`advanced partyId=${party.id} to item="${next.itemName}" (${party.queueIndex + 1}/${party.queue.length})`);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -171,6 +185,7 @@ class PartyServiceClass {
|
||||||
if (party.lastActivityUtc < cutoff) {
|
if (party.lastActivityUtc < cutoff) {
|
||||||
this.partiesById.delete(party.id);
|
this.partiesById.delete(party.id);
|
||||||
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
|
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
|
||||||
|
TranscodeService.endSession(party.id); // same leak - see the "leave" destroy path above
|
||||||
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
|
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
|
||||||
Logger.party(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
|
Logger.party(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +223,9 @@ class PartyServiceClass {
|
||||||
positionTicks: m.positionTicks,
|
positionTicks: m.positionTicks,
|
||||||
connected: !!m.connection
|
connected: !!m.connection
|
||||||
})),
|
})),
|
||||||
music: party.mediaType === 'music' ? MusicQueueService.getState(party) : null
|
music: party.mediaType === 'music' ? MusicQueueService.getState(party) : null,
|
||||||
|
burnInSubtitleIndex: party.burnInSubtitleIndex,
|
||||||
|
burnInAudioIndex: party.burnInAudioIndex
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
299
server/src/services/TranscodeService.ts
Normal file
299
server/src/services/TranscodeService.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import { spawn, type ChildProcess } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { EMBY_SERVER_URL } from '../config';
|
||||||
|
import { Logger } from '../Logger';
|
||||||
|
import { CatalogService } from './CatalogService';
|
||||||
|
import type { EmbySession, Party } from '../types';
|
||||||
|
|
||||||
|
/** The one shape TranscodeService needs out of Emby's own MediaStreams
|
||||||
|
* array - see absoluteToRelativeIndex below. */
|
||||||
|
export interface MediaStreamInfo {
|
||||||
|
Type: string;
|
||||||
|
Index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts Emby's own *absolute* MediaStreams[].Index - a single flat
|
||||||
|
* index across video+audio+subtitle streams together (the "stream 6/7/8"
|
||||||
|
* this app's own subtitle picker already surfaces, see
|
||||||
|
* CatalogService.getPlaybackOptions) - into ffmpeg's *type-relative*
|
||||||
|
* stream specifier: `0:s:N`/`0:a:N` count only among streams of that same
|
||||||
|
* type. ffmpeg has no notion of Emby's absolute numbering at all, so
|
||||||
|
* every burn-in command has to make this conversion first. Pure array
|
||||||
|
* math - no ffmpeg or Emby involved, see transcodeService.test.ts.
|
||||||
|
*/
|
||||||
|
export function absoluteToRelativeIndex(streams: MediaStreamInfo[], type: string, absoluteIndex: number): number {
|
||||||
|
let relative = 0;
|
||||||
|
for (const s of streams) {
|
||||||
|
if (s.Index === absoluteIndex) return relative;
|
||||||
|
if (s.Type === type) relative += 1;
|
||||||
|
}
|
||||||
|
return 0; // not found - callers only ever pass an index getPlaybackOptions itself produced
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emby's own "direct/static" file endpoint (untranscoded, original
|
||||||
|
* bytes, Range-seekable) - the standard Emby/Jellyfin shape, distinct
|
||||||
|
* from `/Videos/{id}/master.m3u8` (Emby's *own* transcode, what
|
||||||
|
* CatalogService.proxyStream proxies for every other party). Feeding
|
||||||
|
* this to our own ffmpeg is what lets this app burn a PGS subtitle in
|
||||||
|
* itself instead of asking Emby's engine to (see docs/architecture.md's
|
||||||
|
* "Self-hosted ffmpeg" section for why that kept failing).
|
||||||
|
*
|
||||||
|
* TODO(verify): unconfirmed against a real Emby server - there isn't one
|
||||||
|
* reachable from this dev environment. If the exact shape is wrong, it's
|
||||||
|
* isolated to this one function.
|
||||||
|
*/
|
||||||
|
function buildSourceUrl(itemId: string, mediaSourceId: string, container: string): string {
|
||||||
|
const params = new URLSearchParams({ Static: 'true', MediaSourceId: mediaSourceId });
|
||||||
|
return `${EMBY_SERVER_URL}/Videos/${itemId}/stream.${container}?${params}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEGMENT_NAME_RE = /^seg\d{5}\.ts$/;
|
||||||
|
|
||||||
|
interface TranscodeSession {
|
||||||
|
partyId: string;
|
||||||
|
audioIndex: string;
|
||||||
|
subtitleIndex: string;
|
||||||
|
process: ChildProcess;
|
||||||
|
outputDir: string;
|
||||||
|
lastAccessUtc: number;
|
||||||
|
/** Set once the process exits before ever producing a first segment -
|
||||||
|
* the tail of its stderr, so a route waiting on this session can fail
|
||||||
|
* the request with a real reason instead of hanging until its own
|
||||||
|
* timeout. Left null for a clean exit after having streamed fine (the
|
||||||
|
* party just moved on - not a failure worth surfacing). */
|
||||||
|
startupFailure: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs ffmpeg ourselves to burn a PGS/VobSub/DVB (image, not text)
|
||||||
|
* subtitle into the video - the one gap the client-side `<track>`
|
||||||
|
* redesign can't close (there's no text to extract). See
|
||||||
|
* docs/architecture.md's "Self-hosted ffmpeg" section for the full
|
||||||
|
* design, and CatalogService.proxyStream's own comment for why Emby's
|
||||||
|
* own transcode was never asked to do this in the first place.
|
||||||
|
*
|
||||||
|
* One session per PARTY, not per viewer: every member of a video party
|
||||||
|
* shares the exact same burn-in stream, the same way everyone already
|
||||||
|
* shares one playback position driven by the host (see
|
||||||
|
* docs/architecture.md's "host drives, everyone follows") - so this is
|
||||||
|
* keyed by partyId, and picking a burn-in subtitle is a host-only,
|
||||||
|
* party-wide choice (SocketGateway's "setBurnInSubtitle" handler), not a
|
||||||
|
* per-viewer local one the way a text subtitle pick still is.
|
||||||
|
*/
|
||||||
|
/** Simplified down to exactly the arguments this service ever passes -
|
||||||
|
* the real `spawn` (more optional params, incl. options) is still a
|
||||||
|
* valid default value for this type. */
|
||||||
|
type SpawnFn = (command: string, args: string[]) => ChildProcess;
|
||||||
|
|
||||||
|
class TranscodeServiceClass {
|
||||||
|
private sessions = new Map<string, TranscodeSession>();
|
||||||
|
private spawnFn: SpawnFn = spawn;
|
||||||
|
|
||||||
|
/** Test-only seam (see transcodeService.test.ts) so a test never
|
||||||
|
* actually invokes a real ffmpeg binary - never called in production.
|
||||||
|
* Pass the real `spawn` back (or omit the argument) to restore it. */
|
||||||
|
setSpawnForTesting(fn: SpawnFn = spawn): void {
|
||||||
|
this.spawnFn = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the party's current burn-in session, starting one if none
|
||||||
|
* exists yet, or restarting it if the party's now asking for a
|
||||||
|
* different audio/subtitle combination than whatever was already
|
||||||
|
* running. Every call refreshes `lastAccessUtc` - this is also how a
|
||||||
|
* still-being-watched session avoids the idle reaper.
|
||||||
|
*/
|
||||||
|
async getOrStartSession(party: Party, session: EmbySession, audioIndex: string, subtitleIndex: string): Promise<TranscodeSession> {
|
||||||
|
const existing = this.sessions.get(party.id);
|
||||||
|
if (existing && existing.audioIndex === audioIndex && existing.subtitleIndex === subtitleIndex) {
|
||||||
|
existing.lastAccessUtc = Date.now();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
if (existing) this.endSession(party.id);
|
||||||
|
|
||||||
|
const source = await CatalogService.getMediaSource(session, party.itemId);
|
||||||
|
const audioRelative = audioIndex ? absoluteToRelativeIndex(source.streams, 'Audio', Number(audioIndex)) : 0;
|
||||||
|
const subtitleRelative = absoluteToRelativeIndex(source.streams, 'Subtitle', Number(subtitleIndex));
|
||||||
|
const sourceUrl = buildSourceUrl(party.itemId, source.id, source.container);
|
||||||
|
|
||||||
|
const outputDir = path.join(os.tmpdir(), 'watchparty-transcode', party.id);
|
||||||
|
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-headers', `X-Emby-Token: ${session.embyToken}\r\n`,
|
||||||
|
'-i', sourceUrl,
|
||||||
|
'-filter_complex', `[0:v][0:s:${subtitleRelative}]overlay[v]`,
|
||||||
|
'-map', '[v]',
|
||||||
|
'-map', `0:a:${audioRelative}`,
|
||||||
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
|
||||||
|
'-c:a', 'aac', '-b:a', '192k',
|
||||||
|
// A known filter_complex+HLS gotcha, not something confirmed to
|
||||||
|
// happen against this app's own files - included defensively.
|
||||||
|
'-max_muxing_queue_size', '1024',
|
||||||
|
'-f', 'hls', '-hls_time', '6',
|
||||||
|
'-hls_flags', 'independent_segments+append_list',
|
||||||
|
'-hls_segment_filename', path.join(outputDir, 'seg%05d.ts'),
|
||||||
|
'-y', path.join(outputDir, 'master.m3u8')
|
||||||
|
];
|
||||||
|
|
||||||
|
const proc = this.spawnFn('ffmpeg', args);
|
||||||
|
const newSession: TranscodeSession = {
|
||||||
|
partyId: party.id,
|
||||||
|
audioIndex,
|
||||||
|
subtitleIndex,
|
||||||
|
process: proc,
|
||||||
|
outputDir,
|
||||||
|
lastAccessUtc: Date.now(),
|
||||||
|
startupFailure: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const stderrTail: string[] = [];
|
||||||
|
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
stderrTail.push(chunk.toString());
|
||||||
|
if (stderrTail.length > 50) stderrTail.shift();
|
||||||
|
});
|
||||||
|
proc.on('exit', (code) => {
|
||||||
|
Logger.transcode(`partyId=${party.id} ffmpeg exited code=${code}`);
|
||||||
|
if (code !== 0 && !this.hasProducedSegment(newSession)) {
|
||||||
|
newSession.startupFailure = stderrTail.join('').slice(-2000) || `ffmpeg exited with code ${code}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
// e.g. ffmpeg isn't on PATH at all - spawn itself failing, not the
|
||||||
|
// process exiting - see the Dockerfile's `apk add ffmpeg`.
|
||||||
|
Logger.transcode(`partyId=${party.id} failed to start ffmpeg: ${err.message}`);
|
||||||
|
newSession.startupFailure = err.message;
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.transcode(
|
||||||
|
`partyId=${party.id} started itemId=${party.itemId} ` +
|
||||||
|
`audioIndex=${audioIndex || '(default)'} subtitleIndex=${subtitleIndex}`
|
||||||
|
);
|
||||||
|
this.sessions.set(party.id, newSession);
|
||||||
|
return newSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kills the process (if any) and removes its temp output directory -
|
||||||
|
* called on a fresh pick replacing this one, the party ending
|
||||||
|
* (PartyService.leave's destroy path / removeIdleParties), or the
|
||||||
|
* idle reaper below. A harmless no-op if this party never had one. */
|
||||||
|
endSession(partyId: string): void {
|
||||||
|
const s = this.sessions.get(partyId);
|
||||||
|
if (!s) return;
|
||||||
|
this.sessions.delete(partyId);
|
||||||
|
s.process.kill('SIGKILL');
|
||||||
|
fs.rm(s.outputDir, { recursive: true, force: true }, () => {});
|
||||||
|
Logger.transcode(`partyId=${partyId} ended`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backstop for parties that don't end cleanly (a crash, every
|
||||||
|
* member's tab just closing) - mirrors PartyService.removeIdleParties,
|
||||||
|
* same wiring in index.ts's own setInterval. */
|
||||||
|
reapIdleSessions(idleTimeoutMs: number): void {
|
||||||
|
const cutoff = Date.now() - idleTimeoutMs;
|
||||||
|
for (const [partyId, s] of this.sessions) {
|
||||||
|
if (s.lastAccessUtc < cutoff) {
|
||||||
|
Logger.transcode(`partyId=${partyId} reaped (idle)`);
|
||||||
|
this.endSession(partyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/catalog/burn/:partyId/master.m3u8 - the caller (index.ts)
|
||||||
|
* has already checked the requester is a member of `party` and that
|
||||||
|
* `party.burnInSubtitleIndex` is actually set. Waits briefly for the
|
||||||
|
* first segment to exist (ffmpeg writes the playlist file itself
|
||||||
|
* almost immediately, but with zero segments listed until the first
|
||||||
|
* one finishes - serving it before then would just hand hls.js an
|
||||||
|
* empty manifest) before serving the current playlist contents.
|
||||||
|
*/
|
||||||
|
async serveMasterPlaylist(req: Request, res: Response, party: Party, embySession: EmbySession): Promise<void> {
|
||||||
|
let s: TranscodeSession;
|
||||||
|
try {
|
||||||
|
s = await this.getOrStartSession(party, embySession, party.burnInAudioIndex || '', party.burnInSubtitleIndex!);
|
||||||
|
} catch (err) {
|
||||||
|
Logger.transcode(`partyId=${party.id} failed to negotiate source: ${(err as Error).message}`);
|
||||||
|
res.status(502).json({ error: "Impossible de préparer le flux incrusté." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await this.waitUntil(() => this.hasProducedSegment(s) || !!s.startupFailure, 20000);
|
||||||
|
if (s.startupFailure) {
|
||||||
|
res.status(502).json({ error: 'ffmpeg a échoué au démarrage du flux incrusté.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
res.status(504).json({ error: 'Le flux incrusté met trop de temps à démarrer.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistPath = path.join(s.outputDir, 'master.m3u8');
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||||
|
fs.createReadStream(playlistPath).pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/catalog/burn/:partyId/:segment - `segment` is validated
|
||||||
|
* against the exact filename shape ffmpeg itself produces before ever
|
||||||
|
* touching the filesystem with it (never trust a URL param as a path
|
||||||
|
* component - see proxyRawStreamAsset's own equivalent guard). */
|
||||||
|
async serveSegment(req: Request, res: Response, party: Party, segment: string): Promise<void> {
|
||||||
|
if (!SEGMENT_NAME_RE.test(segment)) {
|
||||||
|
res.status(400).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = this.sessions.get(party.id);
|
||||||
|
if (!s) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.lastAccessUtc = Date.now();
|
||||||
|
|
||||||
|
const segmentPath = path.join(s.outputDir, segment);
|
||||||
|
const ok = await this.waitUntil(() => fs.existsSync(segmentPath) || !!s.startupFailure, 15000);
|
||||||
|
if (!ok || !fs.existsSync(segmentPath)) {
|
||||||
|
res.status(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'video/mp2t');
|
||||||
|
fs.createReadStream(segmentPath).pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether ffmpeg has written at least one real segment yet - checked
|
||||||
|
* by reading the output directory rather than tracked via an event,
|
||||||
|
* since ffmpeg's own HLS muxer gives no signal for "first segment
|
||||||
|
* flushed" beyond the file simply existing on disk. */
|
||||||
|
private hasProducedSegment(s: TranscodeSession): boolean {
|
||||||
|
try {
|
||||||
|
return fs.readdirSync(s.outputDir).some((f) => SEGMENT_NAME_RE.test(f));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitUntil(predicate: () => boolean, timeoutMs: number): Promise<boolean> {
|
||||||
|
if (predicate()) return Promise.resolve(true);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (predicate()) {
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve(true);
|
||||||
|
} else if (Date.now() >= deadline) {
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TranscodeService = new TranscodeServiceClass();
|
||||||
|
|
@ -33,6 +33,13 @@ export interface ClientToServerEvents {
|
||||||
musicPlaystate: (msg: { isPaused: boolean }) => void;
|
musicPlaystate: (msg: { isPaused: boolean }) => void;
|
||||||
requestPause: () => void;
|
requestPause: () => void;
|
||||||
requestResume: () => void;
|
requestResume: () => void;
|
||||||
|
/** Host-only (checked server-side exactly like "next") - picks which
|
||||||
|
* audio/subtitle combination the party's *shared* burn-in stream uses,
|
||||||
|
* see TranscodeService. A null subtitleIndex clears it, dropping the
|
||||||
|
* party back to the normal per-viewer Emby-proxied stream. Not for
|
||||||
|
* text subtitles (those stay the existing per-viewer client-side
|
||||||
|
* <track>, no server involvement) - see docs/architecture.md. */
|
||||||
|
setBurnInSubtitle: (msg: { subtitleIndex: string | null; audioIndex?: string }) => void;
|
||||||
leave: () => void;
|
leave: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,6 +76,13 @@ export interface ServerToClientEvents {
|
||||||
* inert (not attached to the party) until the client either re-sends
|
* inert (not attached to the party) until the client either re-sends
|
||||||
* "join" with force:true or gives up. */
|
* "join" with force:true or gives up. */
|
||||||
deviceConflict: (msg: { partyId: string }) => void;
|
deviceConflict: (msg: { partyId: string }) => void;
|
||||||
|
/** Broadcast to the whole party room whenever the host changes (or
|
||||||
|
* clears) the party's shared burn-in subtitle/audio choice - see
|
||||||
|
* "setBurnInSubtitle" above and TranscodeService. Mirrors party.burnIn*
|
||||||
|
* below, which is what a fresh "getParty" (a follower joining
|
||||||
|
* mid-party) reads instead - this event is only for someone already
|
||||||
|
* connected when it changes live. */
|
||||||
|
burnInChanged: (msg: { subtitleIndex: string | null; audioIndex: string | null }) => void;
|
||||||
errorMessage: (msg: { message: string }) => void;
|
errorMessage: (msg: { message: string }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,6 +225,16 @@ export interface Party {
|
||||||
* === 'music'. Owned and shaped entirely by MusicQueueService; every
|
* === 'music'. Owned and shaped entirely by MusicQueueService; every
|
||||||
* other service treats this as an opaque bag it just carries around. */
|
* other service treats this as an opaque bag it just carries around. */
|
||||||
music?: MusicState;
|
music?: MusicState;
|
||||||
|
/** Video only - the host's current pick for the party's *shared*
|
||||||
|
* burn-in stream (see TranscodeService), null when nobody's picked one
|
||||||
|
* and the party is just using the normal per-viewer Emby-proxied
|
||||||
|
* stream. Both null or both set - never one without the other, see
|
||||||
|
* SocketGateway's "setBurnInSubtitle" handler. Persisted here (not
|
||||||
|
* just broadcast live) so a follower joining mid-party, or a page
|
||||||
|
* reload, picks up the party's current choice from a plain
|
||||||
|
* "getParty" - same reason itemId/queueIndex live here too. */
|
||||||
|
burnInSubtitleIndex: string | null;
|
||||||
|
burnInAudioIndex: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicMember {
|
export interface PublicMember {
|
||||||
|
|
@ -240,6 +264,8 @@ export interface PartyPublicState {
|
||||||
youAreHost: boolean;
|
youAreHost: boolean;
|
||||||
members: PublicMember[];
|
members: PublicMember[];
|
||||||
music: MusicPublicState | null;
|
music: MusicPublicState | null;
|
||||||
|
burnInSubtitleIndex: string | null;
|
||||||
|
burnInAudioIndex: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LobbyPartyDto {
|
export interface LobbyPartyDto {
|
||||||
|
|
|
||||||
|
|
@ -124,4 +124,37 @@ describe('CatalogService', () => {
|
||||||
(err: Error & { status?: number }) => err.status === 502
|
(err: Error & { status?: number }) => err.status === 502
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('getMediaSource returns the negotiated id/container/streams together - what TranscodeService needs', async (t) => {
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
|
||||||
|
MediaSources: [{
|
||||||
|
Id: 'src9',
|
||||||
|
Container: 'mkv',
|
||||||
|
MediaStreams: [{ Type: 'Video', Index: 0 }, { Type: 'Subtitle', Index: 1 }]
|
||||||
|
}]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const source = await CatalogService.getMediaSource(session, 'item1');
|
||||||
|
|
||||||
|
assert.equal(source.id, 'src9');
|
||||||
|
assert.equal(source.container, 'mkv');
|
||||||
|
assert.equal(source.streams.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getMediaSource falls back to "mkv" when Emby omits Container', async (t) => {
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src10' }] }));
|
||||||
|
|
||||||
|
const source = await CatalogService.getMediaSource(session, 'item1');
|
||||||
|
|
||||||
|
assert.equal(source.container, 'mkv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getMediaSource fails cleanly (not a crash) when Emby has no media source for this item', async (t) => {
|
||||||
|
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [] }));
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => CatalogService.getMediaSource(session, 'missing'),
|
||||||
|
(err: Error & { status?: number }) => err.status === 502
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ function fakeParty(id: string): Party {
|
||||||
hostUserId: 'u-alice',
|
hostUserId: 'u-alice',
|
||||||
createdUtc: Date.now(),
|
createdUtc: Date.now(),
|
||||||
lastActivityUtc: Date.now(),
|
lastActivityUtc: Date.now(),
|
||||||
members: new Map()
|
members: new Map(),
|
||||||
|
burnInSubtitleIndex: null,
|
||||||
|
burnInAudioIndex: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ describe('PartyService', () => {
|
||||||
assert.equal(party.mediaType, 'video');
|
assert.equal(party.mediaType, 'video');
|
||||||
assert.equal(party.members.size, 1);
|
assert.equal(party.members.size, 1);
|
||||||
assert.equal(party.hostUserId, 'u-alice');
|
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', () => {
|
it('advanceQueue moves through the fixed queue and stops at the end', () => {
|
||||||
|
|
@ -25,6 +27,20 @@ describe('PartyService', () => {
|
||||||
assert.equal(PartyService.advanceQueue(party), false, 'nothing left after the last item');
|
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', () => {
|
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');
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
PartyService.join(party, 'u-bob', 'Bob');
|
PartyService.join(party, 'u-bob', 'Bob');
|
||||||
|
|
@ -86,4 +102,15 @@ describe('PartyService', () => {
|
||||||
assert.equal(state.youAreHost, true);
|
assert.equal(state.youAreHost, true);
|
||||||
assert.equal(state.music, null);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -365,4 +365,57 @@ describe('SocketGateway', () => {
|
||||||
assert.equal(party.members.get('u-alice')?.connection, socketA2);
|
assert.equal(party.members.get('u-alice')?.connection, socketA2);
|
||||||
assert.equal(party.members.get('u-alice')?.deviceId, 'device-A');
|
assert.equal(party.members.get('u-alice')?.deviceId, 'device-A');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('setBurnInSubtitle is host-only and a no-op for anyone else (same pattern as "next")', () => {
|
||||||
|
const io = fakeIo();
|
||||||
|
attach(io);
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
|
PartyService.join(party, 'u-bob', 'Bob');
|
||||||
|
|
||||||
|
const bobSocket = fakeSocket('u-bob');
|
||||||
|
io.trigger('connection', bobSocket as unknown as AppSocket);
|
||||||
|
bobSocket.trigger('join', { partyId: party.id, deviceId: 'device-bob' });
|
||||||
|
|
||||||
|
bobSocket.trigger('setBurnInSubtitle', { subtitleIndex: '4', audioIndex: '2' });
|
||||||
|
|
||||||
|
assert.equal(party.burnInSubtitleIndex, null, 'a non-host request must not change party state');
|
||||||
|
assert.equal(party.burnInAudioIndex, null);
|
||||||
|
assert.ok(!io.roomEmits.some((e) => e.event === 'burnInChanged'), 'and must not broadcast anything');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setBurnInSubtitle from the host updates the party and broadcasts burnInChanged to the room', () => {
|
||||||
|
const io = fakeIo();
|
||||||
|
attach(io);
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
||||||
|
const aliceSocket = fakeSocket('u-alice');
|
||||||
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
||||||
|
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
|
||||||
|
|
||||||
|
aliceSocket.trigger('setBurnInSubtitle', { subtitleIndex: '4', audioIndex: '2' });
|
||||||
|
|
||||||
|
assert.equal(party.burnInSubtitleIndex, '4');
|
||||||
|
assert.equal(party.burnInAudioIndex, '2');
|
||||||
|
const broadcast = io.roomEmits.find((e) => e.room === party.id && e.event === 'burnInChanged');
|
||||||
|
assert.deepEqual(broadcast?.payload, { subtitleIndex: '4', audioIndex: '2' });
|
||||||
|
|
||||||
|
// Clearing it (a text subtitle/"Aucun" pick) resets both fields
|
||||||
|
// together, never leaving a stray audioIndex behind with no subtitle.
|
||||||
|
aliceSocket.trigger('setBurnInSubtitle', { subtitleIndex: null });
|
||||||
|
assert.equal(party.burnInSubtitleIndex, null);
|
||||||
|
assert.equal(party.burnInAudioIndex, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setBurnInSubtitle is a no-op on a music party (no host to drive it, no video stream either)', () => {
|
||||||
|
const io = fakeIo();
|
||||||
|
attach(io);
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
|
||||||
|
const aliceSocket = fakeSocket('u-alice');
|
||||||
|
io.trigger('connection', aliceSocket as unknown as AppSocket);
|
||||||
|
aliceSocket.trigger('join', { partyId: party.id, deviceId: 'device-alice' });
|
||||||
|
|
||||||
|
aliceSocket.trigger('setBurnInSubtitle', { subtitleIndex: '4' });
|
||||||
|
|
||||||
|
assert.equal(party.burnInSubtitleIndex, null);
|
||||||
|
assert.ok(!io.roomEmits.some((e) => e.event === 'burnInChanged'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
193
server/test/transcodeService.test.ts
Normal file
193
server/test/transcodeService.test.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
// Exercises TranscodeService's own pure logic without ever invoking a real
|
||||||
|
// ffmpeg binary - CatalogService.getMediaSource and child_process.spawn are
|
||||||
|
// both mocked (see setSpawnForTesting's own comment), same "mock the one
|
||||||
|
// real dependency, run everything else for real" approach as
|
||||||
|
// embyClient.test.ts/socketGateway.test.ts elsewhere in this suite.
|
||||||
|
import { describe, it, mock } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { CatalogService } from '../src/services/CatalogService';
|
||||||
|
import { PartyService } from '../src/services/PartyService';
|
||||||
|
import { TranscodeService, absoluteToRelativeIndex } from '../src/services/TranscodeService';
|
||||||
|
import type { EmbySession } from '../src/types';
|
||||||
|
|
||||||
|
const FAKE_SESSION: EmbySession = {
|
||||||
|
embyToken: 'tok-123',
|
||||||
|
userId: 'u-alice',
|
||||||
|
userName: 'Alice',
|
||||||
|
serverId: 'server1',
|
||||||
|
createdUtc: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mirrors the shape this app's own earlier subtitle-debugging session ran
|
||||||
|
// into for real: a flat, absolute index across video+audio+subtitle
|
||||||
|
// together (see absoluteToRelativeIndex's own comment).
|
||||||
|
const FAKE_STREAMS = [
|
||||||
|
{ Type: 'Video', Index: 0 },
|
||||||
|
{ Type: 'Audio', Index: 1 },
|
||||||
|
{ Type: 'Audio', Index: 2 },
|
||||||
|
{ Type: 'Subtitle', Index: 3 },
|
||||||
|
{ Type: 'Subtitle', Index: 4 }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A minimal stand-in for Node's ChildProcess - just enough of the real
|
||||||
|
* EventEmitter-based shape for TranscodeService to attach its
|
||||||
|
* stderr/exit/error listeners without throwing. */
|
||||||
|
function fakeChildProcess() {
|
||||||
|
const proc = new EventEmitter() as unknown as EventEmitter & { stderr: EventEmitter; kill: ReturnType<typeof mock.fn> };
|
||||||
|
proc.stderr = new EventEmitter();
|
||||||
|
proc.kill = mock.fn();
|
||||||
|
return proc;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('absoluteToRelativeIndex', () => {
|
||||||
|
it('counts only among streams of the same type, in source order', () => {
|
||||||
|
assert.equal(absoluteToRelativeIndex(FAKE_STREAMS, 'Subtitle', 3), 0, 'first subtitle stream');
|
||||||
|
assert.equal(absoluteToRelativeIndex(FAKE_STREAMS, 'Subtitle', 4), 1, 'second subtitle stream');
|
||||||
|
assert.equal(absoluteToRelativeIndex(FAKE_STREAMS, 'Audio', 1), 0, 'first audio stream');
|
||||||
|
assert.equal(absoluteToRelativeIndex(FAKE_STREAMS, 'Audio', 2), 1, 'second audio stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to 0 for an index that is not present, rather than throwing', () => {
|
||||||
|
assert.equal(absoluteToRelativeIndex(FAKE_STREAMS, 'Subtitle', 99), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TranscodeService', () => {
|
||||||
|
it('starting a session spawns ffmpeg with relative stream specifiers, not Emby\'s absolute indices', async (t) => {
|
||||||
|
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
||||||
|
const spawned: { command: string; args: string[] }[] = [];
|
||||||
|
TranscodeService.setSpawnForTesting((command, args) => {
|
||||||
|
spawned.push({ command, args });
|
||||||
|
return fakeChildProcess() as never;
|
||||||
|
});
|
||||||
|
t.after(() => TranscodeService.setSpawnForTesting());
|
||||||
|
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'movie1', 'Un Film', '/img/movie1', null, 'video');
|
||||||
|
await TranscodeService.getOrStartSession(party, FAKE_SESSION, '2', '4');
|
||||||
|
|
||||||
|
assert.equal(spawned.length, 1);
|
||||||
|
assert.equal(spawned[0]!.command, 'ffmpeg');
|
||||||
|
assert.ok(spawned[0]!.args.includes('[0:v][0:s:1]overlay[v]'), 'absolute subtitle index 4 -> relative 1');
|
||||||
|
assert.ok(spawned[0]!.args.includes('0:a:1'), 'absolute audio index 2 -> relative 1');
|
||||||
|
|
||||||
|
TranscodeService.endSession(party.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty audioIndex (no override) maps to the first audio stream', async (t) => {
|
||||||
|
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
||||||
|
const spawned: { args: string[] }[] = [];
|
||||||
|
TranscodeService.setSpawnForTesting((_command, args) => {
|
||||||
|
spawned.push({ args });
|
||||||
|
return fakeChildProcess() as never;
|
||||||
|
});
|
||||||
|
t.after(() => TranscodeService.setSpawnForTesting());
|
||||||
|
|
||||||
|
const party = PartyService.create('u-alice', 'Alice', 'movie1b', 'Un Film', '/img/movie1b', null, 'video');
|
||||||
|
await TranscodeService.getOrStartSession(party, FAKE_SESSION, '', '3');
|
||||||
|
|
||||||
|
assert.ok(spawned[0]!.args.includes('0:a:0'));
|
||||||
|
TranscodeService.endSession(party.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a repeat call for the same audio/subtitle pair reuses the running session instead of spawning again', async (t) => {
|
||||||
|
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
||||||
|
let spawnCount = 0;
|
||||||
|
TranscodeService.setSpawnForTesting(() => {
|
||||||
|
spawnCount += 1;
|
||||||
|
return fakeChildProcess() as never;
|
||||||
|
});
|
||||||
|
t.after(() => TranscodeService.setSpawnForTesting());
|
||||||
|
|
||||||
|
const party = PartyService.create('u-bob', 'Bob', 'movie2', 'Un Film 2', '/img/movie2', null, 'video');
|
||||||
|
const first = await TranscodeService.getOrStartSession(party, FAKE_SESSION, '2', '4');
|
||||||
|
const second = await TranscodeService.getOrStartSession(party, FAKE_SESSION, '2', '4');
|
||||||
|
|
||||||
|
assert.equal(spawnCount, 1);
|
||||||
|
assert.equal(first, second, 'must be the exact same session object, not a fresh one');
|
||||||
|
|
||||||
|
TranscodeService.endSession(party.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a different pick for the same party kills the old process before starting a new one', async (t) => {
|
||||||
|
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
||||||
|
const procs: ReturnType<typeof fakeChildProcess>[] = [];
|
||||||
|
TranscodeService.setSpawnForTesting(() => {
|
||||||
|
const proc = fakeChildProcess();
|
||||||
|
procs.push(proc);
|
||||||
|
return proc as never;
|
||||||
|
});
|
||||||
|
t.after(() => TranscodeService.setSpawnForTesting());
|
||||||
|
|
||||||
|
const party = PartyService.create('u-carl', 'Carl', 'movie3', 'Un Film 3', '/img/movie3', null, 'video');
|
||||||
|
await TranscodeService.getOrStartSession(party, FAKE_SESSION, '2', '4');
|
||||||
|
await TranscodeService.getOrStartSession(party, FAKE_SESSION, '', '3');
|
||||||
|
|
||||||
|
assert.equal(procs.length, 2, 'the second pick must spawn a fresh process, not reuse the first');
|
||||||
|
assert.equal(procs[0]!.kill.mock.calls.length, 1, 'the superseded process must be killed');
|
||||||
|
|
||||||
|
TranscodeService.endSession(party.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('endSession kills the process, and a later call for the same party starts fresh (not "already running")', async (t) => {
|
||||||
|
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
||||||
|
let spawnCount = 0;
|
||||||
|
const procs: ReturnType<typeof fakeChildProcess>[] = [];
|
||||||
|
TranscodeService.setSpawnForTesting(() => {
|
||||||
|
spawnCount += 1;
|
||||||
|
const proc = fakeChildProcess();
|
||||||
|
procs.push(proc);
|
||||||
|
return proc as never;
|
||||||
|
});
|
||||||
|
t.after(() => TranscodeService.setSpawnForTesting());
|
||||||
|
|
||||||
|
const party = PartyService.create('u-dave', 'Dave', 'movie4', 'Un Film 4', '/img/movie4', null, 'video');
|
||||||
|
await TranscodeService.getOrStartSession(party, FAKE_SESSION, '2', '4');
|
||||||
|
TranscodeService.endSession(party.id);
|
||||||
|
|
||||||
|
assert.equal(procs[0]!.kill.mock.calls.length, 1);
|
||||||
|
|
||||||
|
await TranscodeService.getOrStartSession(party, FAKE_SESSION, '2', '4');
|
||||||
|
assert.equal(spawnCount, 2, 'ending the session must not leave it "still running" for the next call');
|
||||||
|
|
||||||
|
TranscodeService.endSession(party.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('endSession is a harmless no-op for a party that never had a session', () => {
|
||||||
|
assert.doesNotThrow(() => TranscodeService.endSession('no-such-party'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reapIdleSessions ends only sessions whose last access is past the cutoff', async (t) => {
|
||||||
|
t.mock.method(CatalogService, 'getMediaSource', async () => ({ id: 'src1', container: 'mkv', streams: FAKE_STREAMS }));
|
||||||
|
TranscodeService.setSpawnForTesting(() => fakeChildProcess() as never);
|
||||||
|
t.after(() => TranscodeService.setSpawnForTesting());
|
||||||
|
|
||||||
|
const fresh = PartyService.create('u-eve', 'Eve', 'movie5', 'Un Film 5', '/img/movie5', null, 'video');
|
||||||
|
const stale = PartyService.create('u-frank', 'Frank', 'movie6', 'Un Film 6', '/img/movie6', null, 'video');
|
||||||
|
const freshSession = await TranscodeService.getOrStartSession(fresh, FAKE_SESSION, '', '3');
|
||||||
|
const staleSession = await TranscodeService.getOrStartSession(stale, FAKE_SESSION, '', '3');
|
||||||
|
|
||||||
|
// Simulate "stale" having gone untouched for a long time, without
|
||||||
|
// waiting for real wall-clock time to pass.
|
||||||
|
staleSession.lastAccessUtc = Date.now() - 60 * 60 * 1000;
|
||||||
|
|
||||||
|
TranscodeService.reapIdleSessions(30 * 60 * 1000);
|
||||||
|
|
||||||
|
// The stale party's session should be gone (a fresh call spawns
|
||||||
|
// again); the fresh one should still be the exact same session.
|
||||||
|
let spawnCountAfterReap = 0;
|
||||||
|
TranscodeService.setSpawnForTesting(() => {
|
||||||
|
spawnCountAfterReap += 1;
|
||||||
|
return fakeChildProcess() as never;
|
||||||
|
});
|
||||||
|
const stillFresh = await TranscodeService.getOrStartSession(fresh, FAKE_SESSION, '', '3');
|
||||||
|
assert.equal(spawnCountAfterReap, 0, 'the fresh session must not have been reaped');
|
||||||
|
assert.equal(stillFresh, freshSession);
|
||||||
|
|
||||||
|
await TranscodeService.getOrStartSession(stale, FAKE_SESSION, '', '3');
|
||||||
|
assert.equal(spawnCountAfterReap, 1, 'the stale session must have been reaped and started fresh');
|
||||||
|
|
||||||
|
TranscodeService.endSession(fresh.id);
|
||||||
|
TranscodeService.endSession(stale.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue