diff --git a/README.md b/README.md index 1adf8c3..9b93715 100644 --- a/README.md +++ b/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 toolbar lets you ask the host to pause or resume — everyone sees the 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 playback — anyone can add more tracks to the shared queue at any time, 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 build as the thing to double-check, even though it mirrors exactly the `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 @@ -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 and queue tracks while listening; starting a video party always 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) - [ ] Tune transcoding target / support direct play when possible diff --git a/client/src/api/embyApi.ts b/client/src/api/embyApi.ts index cbbc1fb..6f39bbd 100644 --- a/client/src/api/embyApi.ts +++ b/client/src/api/embyApi.ts @@ -132,3 +132,13 @@ export function audioStreamUrl(itemId: string): string { export function subtitleUrl(itemId: string, subtitleIndex: number): string { 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 + * 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`; +} diff --git a/client/src/api/party.ts b/client/src/api/party.ts index 913182a..1a2548a 100644 --- a/client/src/api/party.ts +++ b/client/src/api/party.ts @@ -8,7 +8,7 @@ // No join codes: joining happens straight from the home page's live list // (connectLobby below) by party id - see docs/architecture.md. 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(path: string, options: RequestInit = {}): Promise { const res = await fetch(path, { @@ -85,6 +85,9 @@ export interface PartyConnectionCallbacks { * calls confirmDeviceTakeover() or gives up (e.g. navigates away). See * SocketGateway's "join" handler. */ 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; onClose?: () => void; } @@ -108,6 +111,12 @@ export interface PartyConnection { * disconnect their other device and take over here - call this from * onDeviceConflict's handler once the user says yes. */ 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 +

Flux incrusté actif pour toute la party - langue et qualité fixées tant qu'il l'est.

diff --git a/client/src/views/player/Player.ts b/client/src/views/player/Player.ts index 071f7eb..30673d2 100644 --- a/client/src/views/player/Player.ts +++ b/client/src/views/player/Player.ts @@ -1,13 +1,13 @@ import '../../styles/presence.css'; import './Player.css'; 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 type { PartyConnection } from '../../api/party'; import { navigate } from '../../router'; import type { CleanupFn, RouteParams } from '../../router'; 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'; 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 subtitleSelect = root.querySelector('#subtitleSelect') as HTMLSelectElement; const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement; + const burnInHint = root.querySelector('#burnInHint')!; qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => ``).join(''); @@ -173,6 +174,17 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli let pauseRequestCooldownUntil = 0; let resumeRequestCooldownUntil = 0; 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) { loadingNotice.classList.add('hide'); @@ -227,30 +239,67 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); }); 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."); } async function loadTrackOptions(itemId: string) { try { const options = await getPlaybackOptions(itemId); + lastPlaybackOptions = options; audioSelect.innerHTML = options.audioTracks.length ? options.audioTracks.map((t) => ``).join('') : ''; - // isTextBased: false means this is an image/bitmap subtitle (PGS/ - // VobSub/DVB, typically a Blu-ray/DVD rip) - rendered as a native - // (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 = '' + - options.subtitleTracks.map((t) => ``).join(''); + renderSubtitleOptions(); } catch (err) { console.error('[watchparty player] playback-options failed', err); // Non-fatal: playback can still work with Emby's own defaults, just without a picker. } } + /** + * (Re)builds the subtitle `'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 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 (`MusicQueueService` has no notion of "jump to position X" today, only 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. diff --git a/server/Dockerfile b/server/Dockerfile index 479bb3b..bef6509 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -28,6 +28,14 @@ RUN cd client && npm run build 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 +# 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 COPY server/package.json server/package-lock.json* ./server/ diff --git a/server/package.json b/server/package.json index ee3d5a9..09d5d1d 100644 --- a/server/package.json +++ b/server/package.json @@ -9,7 +9,7 @@ "start": "node dist/index.js", "dev": "tsx watch src/index.ts", "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": { "node": ">=18" diff --git a/server/src/Logger.ts b/server/src/Logger.ts index 6eff759..10eb9a7 100644 --- a/server/src/Logger.ts +++ b/server/src/Logger.ts @@ -54,4 +54,15 @@ export class Logger { static emby(message: string): void { 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}`); + } } diff --git a/server/src/index.ts b/server/src/index.ts index c2fae8a..e62d8be 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,6 +10,7 @@ import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthServ import { CatalogService, CatalogFetchError } from './services/CatalogService'; import { PartyService } from './services/PartyService'; import { MusicQueueService } from './services/MusicQueueService'; +import { TranscodeService } from './services/TranscodeService'; import { SocketGateway } from './realtime/SocketGateway'; 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')); })); +// 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 --------------------------------------------------------------- // No REST "list parties" endpoint: the home page's "watch parties en cours" // 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)); 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, () => { console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`); diff --git a/server/src/realtime/SocketGateway.ts b/server/src/realtime/SocketGateway.ts index b4d0333..ed3bc2b 100644 --- a/server/src/realtime/SocketGateway.ts +++ b/server/src/realtime/SocketGateway.ts @@ -36,7 +36,14 @@ import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } fr * "playstate" (server -> client, to everyone in the party but the sender) * "next" (host only - advance the queue) * "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 can't render - see + * TranscodeService. A text subtitle never goes through here - that + * stays the existing per-viewer client-side .) + * "burnInChanged" (server -> client) * + * -- mediaType: 'music' parties (see MusicQueueService for the model) -- * "voteSkip" (anyone - see MusicQueueService for the threshold) * "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