Bug report: watch party members still didn't have synced subtitle
settings, and the audio/language selector showed an empty choice on
first load.
Text subtitles were the gap left by the previous "one shared stream
per party" change: that made audio/quality/burn-in host-only and
party-wide, but a plain text subtitle pick (the common case - burn-in
only applies to bitmap PGS/VobSub/DVB tracks) was still each viewer's
own local <track> choice. Once people actually watched together,
"everyone but me changed the subtitle" read as broken sync.
Fixed with a parallel mechanism, not a merge into burnInSubtitleIndex -
new Party.textSubtitleIndex, new setTextSubtitle/textSubtitleChanged
socket pair, mirroring setBurnInSubtitle/burnInChanged's host-only,
request/broadcast/react shape exactly. Kept separate from burn-in
(rather than reusing one field for both) to avoid touching the
already-proven burn-in path - only one subtitle can show at once, so
the two are mutually exclusive, enforced server-side in both
directions: a real pick on either clears the other. Clearing one to
null/"Aucun" is deliberately not treated as a pick for this purpose,
so it doesn't clobber an unrelated choice in the other field.
Mechanically the two still work nothing alike: burn-in changes the
actual encoded stream (ffmpeg), so it reloads for everyone; a text
pick only swaps which native <track> each member already renders
locally - no reload, no transcode, unless it's the one clearing an
active burn-in, which does still need the reload since the stream
itself changes back to the normal shared one.
Player.ts's subtitle <select> gating stopped being conditional on
burn-in - it's simply host-gated now, matching audio/quality, and its
value restores from whichever kind of pick (if any) the party
currently has.
Second, unrelated bug in the same report: audioSelect.value =
streamAudioIndex || '' assigned an empty string whenever no audio
track had been picked yet (the common default) - since a real
audio-track <option> never has value="", this matched nothing and
left the select at selectedIndex=-1, a genuinely empty-looking choice.
Fixed at both call sites (loadTrackOptions, onStreamOptionsChanged) by
only assigning .value when truthy.
Server: SocketGateway tests cover setTextSubtitle's host-only gating,
broadcast, music no-op, and mutual exclusivity in both directions;
PartyService tests cover textSubtitleIndex through create/
advanceQueue/toPublicState. Full typecheck/test/build green on both
projects (91/91 server tests). Real-browser check (mocked-fetch + real
bundle) confirmed: a follower's subtitle select renders disabled
regardless of burn-in state; a host's select restores the party's
current text or burn-in pick on load; and the audio select now lands
on a real first option instead of an empty one.
Not addressed here (raised in the same report, need more diagnostic
detail first): occasional sync losses between clients, and a
suspicion that per-user watch-status (Emby PlaystateService sync)
might be broken.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Asked for directly: exactly one stream should ever be open between this
app and Emby per party, no matter how many members it has. Before this,
every viewer's own <video>/<audio> independently negotiated its own
PlaybackInfo and got its own PlaySessionId - a party of 5 meant Emby
transcoding the same title 5 separate times.
A bigger, higher-regression-risk change than most of this session's
other work: it replaces the core playback path every existing party
already relies on (CatalogService.proxyStream and its route are gone
entirely) rather than adding something alongside it - there's no other
consumer of the per-viewer path, and the ask was explicit that there
should only ever be one mechanism.
- server/src/services/CatalogService.ts: proxyStream's negotiate-then-
build-a-target logic extracted into negotiateStreamTarget(session,
itemId, isAudio, audioStreamIndex, maxStreamingBitrate) - identical
logic, returns the target instead of proxying it directly so it can
be cached. proxyStream/proxyRawStreamAsset removed.
- server/src/services/PartyStreamService.ts (new): one negotiated
target per party.id, reused by every member; renegotiates when the
desired item/audio/quality changes. Recovers from a dead upstream
session with no new plumbing - EmbyClient.proxyResponse already sets
res.statusCode before writing the body, so a failed proxied response
is detected right after it resolves and the cache entry is dropped,
so the next request (a manual retry, or the next segment fetch)
renegotiates fresh instead of repeating the same dead target forever.
- server/src/emby/EmbyClient.ts: rewritePlaylist/proxyResponse's
trailing itemId param genericized to routeKey - it's only ever read
back to build the raw-asset URL, so it's party.id for the new stream
routes and an inert real itemId for proxyImage/proxySubtitle (which
never reach that branch anyway).
- server/src/index.ts: GET /api/catalog/party-stream/:partyId/
master.m3u8 and .../raw replace /api/catalog/stream/:itemId/...
entirely, membership-checked like the burn-in routes; a new idle-reap
interval for PartyStreamService, same shape as TranscodeService's own.
- Forced consequence of "one stream", not a free design choice: audio
track/quality become a host-only, party-wide choice for video -
Party.streamAudioIndex/streamQuality, setStreamOptions/
streamOptionsChanged, byte-for-byte the same shape
setBurnInSubtitle/burnInChanged already established for burn-in
subtitles. server/src/realtime/SocketGateway.ts gains the host-only,
video-only handler; server/src/services/PartyService.ts resets
streamAudioIndex (not streamQuality - a bandwidth preference isn't
item-specific) on advanceQueue, same reasoning as burnIn* already
gets; MusicQueueService.playNext ends the party's cached session on
a track change.
- client/src/api/embyApi.ts: streamUrl/audioStreamUrl replaced by one
partyStreamUrl(partyId) - no query params needed any more, the party
itself is the source of truth for what it's streaming.
- client/src/api/party.ts: setStreamOptions/onStreamOptionsChanged.
- client/src/views/player/Player.ts: audio/quality <select>s disabled
for a follower (applyStreamOptionsGating, mirroring
renderSubtitleOptions' own gating), restore the party's current
choice on load/broadcast, and a host's change calls setStreamOptions
instead of reloading locally - every member (host included) only
ever reacts to the resulting broadcast, same discipline the rest of
this player already uses. Dead reloadWithCurrentOptions removed.
- client/src/musicBar/MusicBar.ts: loadTrack points at
partyStreamUrl(activePartyId) - no picker to gate, MusicBar never had
one.
- server/test/catalogService.test.ts: proxyStream's old cases moved to
exercise negotiateStreamTarget instead, plus new quality-override and
no-source-fails cases. server/test/partyStreamService.test.ts (new):
cache hit/miss/param-change/item-change, the failure-invalidation
path, endSession, idle reap. server/test/socketGateway.test.ts:
setStreamOptions host-only gating + broadcast, mirroring the existing
setBurnInSubtitle tests.
- docs/architecture.md, README.md: design, the forced audio/quality
trade-off spelled out, and what's still unverified (a different kind
than this session's other Emby-API guesses - the calls themselves
were already live code, what's untested is real multi-viewer load).
Verified: full typecheck+test+build on both projects (85/85 server
tests, 12 new). A real-browser check (mocked-fetch + real bundle, this
session's standing harness) confirmed a follower's audio/quality
selects render disabled and a host's render enabled, from the same
party data with only youAreHost flipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
- server/test/*.test.ts: formalizes this project's many ad-hoc scratchpad
test scripts into a committed suite, one file per service
(musicQueueService, partyService, socketGateway, catalogService), using
Node's built-in node:test + node:assert/strict - zero new dependencies.
Runs straight against TypeScript source via `tsx --test` (npm test), no
compile step.
- server/tsconfig.test.json: extends the base config, overrides rootDir to
"." and adds test/**/*.ts to include, so `npm run typecheck` covers the
test files too without pulling them into dist/ (npm run build still
uses the unmodified base tsconfig, scoped to src/ only).
- server/package.json: typecheck now runs against tsconfig.test.json; new
test script.
- .forgejo/workflows/publish-docker.yml: new `test` job (node:20-alpine
container - the publish job's docker:27-cli has no Node) running server
typecheck+test and client typecheck+build; publish now depends on it via
needs: test. Removed the stale "no test suite yet" comment.
- README.md, docs/architecture.md: documented the test suite, how to run
it locally, and the new CI job/gating.
Verified locally: server typecheck, server test (25/25 passing), server
build, client typecheck, and client build all pass individually - the
same commands the new CI job runs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>