Implements a detailed spec (Google Cast / Chromecast integration) asking
for a member to cast the party's video to a Chromecast's Default Media
Receiver, with play/pause/seek shown as a remote on the sender's own
screen. The spec's description of this app's architecture (one shared
Emby stream per party, the server proxying 100% of Emby access) was
verified directly against the real code before building anything, since
it named specific existing services/routes as the integration points.
A Chromecast is a new kind of client for the existing party-stream/burn
relay: it can't carry the sender's httpOnly session cookie at all, so it
needed its own narrow way in without weakening "the server holds the
only real Emby token" the rest of the app is built around.
- New CastTokenService (same shape as AuthService: in-memory Map, opaque
crypto.randomUUID() keys, TTL-checked) issues a token scoped to one
{partyId, userId}, standing in for that member's own EmbySession.
New POST/DELETE /api/parties/:partyId/cast-token to mint/revoke one.
- The 4 routes a Chromecast actually needs (party-stream/master.m3u8,
party-stream/raw, burn/master.m3u8, burn/:segment) gained one small
fallback (sessionForStream in index.ts): try the session cookie first,
same as ever, then a castToken query param - either way the result is
the same EmbySession type, so PartyStreamService/TranscodeService/
EmbyClient needed zero changes. The image/subtitle routes stay
cookie-only, outside the spec's own 4-route list.
- PartyService.leave() now revokes whatever cast token the leaving
member held, unconditionally - a token dies the instant its owner
actually leaves the party regardless of TTL or whether the client got
a chance to call the explicit stop-casting route.
- New client/src/cast.ts is the only module touching the CAF Sender SDK
(loaded as a pinned CDN script, same pattern as hls.js/socket.io) -
exposes a small PlaybackDriver interface (currentTime/paused/play/
pause) with two implementations, one over the local <video>, one over
CAF's RemotePlayer/RemotePlayerController. This was the only new
abstraction the feature needed, not a new sync mechanism: Player.ts's
existing sendLocalState/applyRemoteState already read and write "the
player" in one place each, so pointing them at whichever driver is
active carried the host-only gate, drift-tolerance reconciliation, and
3s heartbeat over unchanged. Seek was already "the host's next
broadcast" with no separate event, so seeking on the TV is party-wide
for free; a non-host caster already only ever received state, so
redirecting that to the cast driver reproduces "still dependent on the
host" automatically.
- Cast button in the player's action bar, shown to any member (not just
the host) once cast.framework reports a receiver on the network.
Starting a cast tears down the local hls.js instance; ending one
(user-stopped, or the receiver dropping off on its own) resyncs local
playback via the same loadVideo path an audio/quality change already
uses. A real reload for any other reason (item change, burn-in/
subtitle/stream-options change) simply ends an active cast first,
rather than keeping a live session in sync with every kind of party
transition - not worth the complexity for v1.
Scoped to video parties only - the spec's every concrete reference is
video-specific, and MusicBar.ts has no comparable control surface today.
Tests: CastTokenService (issue/resolve/reissue-invalidates-the-old-one/
revoke/wrong-party-rejection), PartyService.leave()'s new call. Full
typecheck/test/build clean on both projects (115/115 server tests).
Confirmed against the real running server that the 4 streaming routes
still reject a request with no auth exactly as before, and now also
reject an invalid castToken - both a plain 401, no regression. Confirmed
in a real browser that the Cast SDK loads and initializes correctly. Not
verified: no real Chromecast or Emby login credentials in this
environment to take a cast session, or the button's own live behavior,
any further - see docs/architecture.md's "Casting to a Chromecast"
section.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Report: "still don't see my Emby profile updated - watched items
should be marked read, partially-watched ones should show in Resume".
After two rounds of fixing PlayingItems/Progress's request shape
(missing MediaSourceId, then missing PlaySessionId), this pointed at
something more fundamental: this app deliberately never calls the
matching PlayingItems "Start"/"Stop" - a documented v1 scope cut - and
a bare Progress report with no corresponding Start may simply never
reach the resume-position/watched-history data real clients read,
regardless of how correctly-shaped the request is.
Confirmed directly against the real server that this data lives
somewhere else entirely, trivially readable/writable with no playback
session required at all: every item Emby returns already carries the
requesting user's own UserData embedded (PlaybackPositionTicks/Played/
PlayCount/IsFavorite/...) - the exact thing Continue Watching and the
watched checkmark actually read - via a plain
POST /Users/{userId}/Items/{itemId}/UserData.
Switched entirely to that instead of layering a third fix onto
PlayingItems:
- PlaystateService.updatePosition (replacing reportProgress, same
~20s sweep in index.ts) updates just PlaybackPositionTicks.
- New PlaystateService.markPlayed sets Played: true and resets
PlaybackPositionTicks to 0 - called once per connected member (with
a token) when the party moves past an item for good:
PartyService.advanceQueue (next episode) and
MusicQueueService.playNext (a track naturally finishing, or a
majority vote-skip - both go through this one method). Addresses
the other half of the report ("watched items marked as watched").
- Both go through one private fetch-merge-write helper: GET the item
first, apply only the field(s) being changed to a copy of its
current UserData, POST the whole result back. Emby's spec documents
the body as the complete UserItemDataDto with nothing indicating
merge-not-replace semantics, and this is the one write in the whole
app where guessing wrong risks actually erasing a user's own real
data (a favorite, a rating) - so never a bare partial POST.
Reverted the MediaSourceId/PlaySessionId plumbing rounds 1-2 added
(CatalogService.negotiateStreamTarget back to returning a bare target
path, PartyStreamService no longer caches either) - nothing needs them
now that UserData doesn't require an active PlayingItems session.
Not yet confirmed end to end against a real server (no safe way to
fire a real, state-mutating test call without one).
Tests: PlaystateService's fetch-merge-write behavior in detail (a
favorite/rating/play-count survives a position-only update byte for
byte, a Played-only update doesn't disturb IsFavorite, a failed read
skips the write entirely, an item with no UserData yet starts from an
empty object), its request/outcome logging including that the token
never leaks into a log line, and that advanceQueue/playNext call
markPlayed for the outgoing item only, only for connected+tokened
members. catalogService/partyStreamService tests reverted to their
simpler pre-existing shape. Full typecheck/test/build green
(107/107 tests). docs/architecture.md and README.md updated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
This app has been read/proxy-only toward Emby so far. Queued right
after the playlist feature, asked for directly: while someone's in a
party, push their progress to Emby's own PlaystateService, so their own
Emby library (resume position, last-played state) stays in sync with
the watch/listen party, under their own account - not just the host's,
and not just this app's own memory of it.
Scoped to periodic progress reporting only for v1 - no explicit
start/stop calls (Emby's own "now playing" list won't show this app's
streams, and "mark fully watched" isn't guaranteed the way a clean Stop
near 100% would trigger it). Steady Progress calls alone already cover
the actual ask; wiring clean start/stop into every transition (item
change, leave, party destroy, a device takeover) is real complexity for
a secondary payoff - left as a documented follow-up.
- server/src/types.ts: PartyMember gains embyToken (null until known,
same shape as this session's own deviceId addition) - never sent to
the client, toPublicState's member mapping doesn't grow it.
- server/src/emby/EmbyClient.ts: fetch() now optionally takes
method/body (JSON-encoded once, here) - every existing GET-only call
site unaffected, both are optional.
- server/src/services/PlaystateService.ts (new): reportProgress(token,
userId, itemId, positionTicks, isPaused) - POSTs to
/Users/{userId}/PlayingItems/{itemId}/Progress. Catches and logs its
own failures (new Logger.playstate) rather than throwing - this runs
on a timer with nothing to catch a rejection, and one bad token or a
transient Emby hiccup must never touch real playback.
- server/src/services/PartyService.ts: new setMemberEmbyToken (records
a member's own token) and getCurrentPlaybackState (video: the host's
own tracked position - there's no per-follower tracking today, a
reasonable approximation given the player's own drift correction;
music: the one shared, server-authoritative position every member
already sees the same way). The one real piece of logic here is pure
and unit-tested on its own, same shape as this session's
absoluteToRelativeIndex/splitPlaylistByMediaType.
- server/src/realtime/SocketGateway.ts: attach()'s getSession callback
widened to expose embyToken - it was already being passed the full
EmbySession (AuthService.getSession), just typed down to {userId,
userName} before anything downstream could read the rest. The "join"
handler now also records member.embyToken alongside deviceId.
- server/src/index.ts: setMemberEmbyToken called after each of the 4
REST create/join call sites, so a member has a valid token from the
moment they're a member, not only once their socket first connects.
New ~20s setInterval (same shape as the existing removeIdleParties/
TranscodeService.reapIdleSessions timers): for every active party,
getCurrentPlaybackState once, then reportProgress for every connected
member with a known token.
- server/test/embyClient.test.ts, partyService.test.ts,
playstateService.test.ts (new): the new body-encoding path (and that
a plain GET stays untouched), getCurrentPlaybackState's video/music/
empty branches, setMemberEmbyToken, and that reportProgress's request
shape is right and a thrown failure never propagates.
- docs/architecture.md, README.md: design + the deliberate v1 scope and
the follower-position approximation spelled out.
No client changes - this is a pure server-side background process with
nothing for the browser to render or react to.
Verified: full typecheck+test+build on both projects (71/71 server
tests, 9 new). No Emby server is reachable from this dev environment,
so the actual endpoint/body shape is unverified end-to-end - flagged
explicitly in the docs, the third such Emby-API assumption this session
(after the ffmpeg direct-stream URL and the Playlists endpoint, both
fine once actually tried).
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>
Asked for directly: "same account, same device already in the party"
should never need a confirmation, "same account, a genuinely different
device" should ask before disconnecting it instead of silently
overwriting. There was no concept of "device" at all before this -
party.members is keyed by userId only.
- client/src/deviceId.ts (new): a UUID kept in localStorage (not
sessionStorage) - that's what makes "device" mean "browser", not
"tab". Falls back to an in-memory id if storage is unavailable.
- server/src/types.ts, client/src/api/party.ts: "join" now carries
{partyId, deviceId, force?} instead of just {partyId}; new
PartyMember.deviceId and "deviceConflict" server->client event (sent
only to the joining socket, never broadcast).
- server/src/realtime/SocketGateway.ts: the "join" handler now tells
three cases apart - no existing connection or the same deviceId
reconnecting (attach silently, booting any stale connection for that
same device first), a different deviceId with no force (emit
deviceConflict and stop - the socket stays connected but inert), or
force:true (the confirmed takeover - boot the old connection, take
the slot).
- server/src/services/PartyService.ts: join() is now idempotent -
returns the existing member instead of always building a fresh one.
This was itself a real bug independent of the guard below: the REST
"Rejoindre" route calls this every time regardless of membership, so
a re-click/reload/retry used to silently reset connection/isPaused/
deviceId.
- server/src/realtime/SocketGateway.ts: "leave"/"disconnect" now also
check party.members.get(userId)?.connection === socket before acting
- generalizes the earlier stale-connection crash fix to the new
reality where a member can have more than one connection attempt in
flight (the superseded one's own eventual "disconnect" must not be
read as a real leave).
- client/src/views/player/Player.ts, client/src/musicBar/MusicBar.ts:
onDeviceConflict wired to a plain confirm() - yes calls the new
PartyConnection.confirmDeviceTakeover() (re-sends "join" with
force:true), no backs out (navigate home for video, leaveMusicParty
for music).
- Deliberately does NOT change the home page's live party list itself
(still just "Rejoindre" either way) - LobbyPartyDto is one snapshot
shared by the whole "lobby" room with no per-viewer membership info,
and giving it that is a real protocol change on its own. For video
the conflict check above already is the meaningful moment (you can't
be looking at the home page from inside your own party on the same
tab - the player is a full-screen takeover), so this doesn't cost
the feature much: Rejoindre reaches the check immediately, same
device reconnects silently, a different device asks first. See
docs/architecture.md for the fuller reasoning.
- server/test/socketGateway.test.ts, server/test/partyService.test.ts:
new tests for the conflict/takeover/reconnect paths and join()'s
idempotency; the earlier stale-connection crash regression test
updated to reach the same shared-slot shape through a real device
takeover instead of a raw PartyService.join() call (which no longer
reproduces it now that join() is idempotent).
Verified: full typecheck+test+build on both projects (39/39 server
tests passing); confirmed the "leave"/"disconnect" guard is load-
bearing by temporarily reverting it and watching the new takeover test
fail, then restoring it; deviceId.ts's localStorage persistence
checked against the real compiled bundle in a real browser (survives
a reload, stable across calls).
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>