10 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| b4c81d820c |
Cast a party's video to a Chromecast
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>
|
|||
| f78b2f1408 |
One shared Emby stream per party, not one per viewer
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> |
|||
| 11f003d269 |
Sync watch-party progress back to Emby (PlaystateService)
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>
|
|||
| 26da8fc5dc |
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>
|
|||
| 1e4a82df16 |
Log Emby's real status + response body on any proxied call failure
Every attempt to see Emby's actual error message by hand failed the same way: hls.js's own error reporting only ever exposes the generic HTTP reason phrase, never the real response body, and Emby's own live log view didn't reliably surface the exception's headline message either - across several different items and failure modes this session. Emby's own access log confirmed a real, several-KB text body really was being sent every time (Content-Type: text/plain, Content-Length: 5190) - it just never survived being reported back through either of those paths. Fixed at the root: EmbyClient.fetch - the one function every Emby call in this app goes through (fetchJson's JSON API calls and proxyResponse's streaming/image proxy alike) - now logs Emby's status and full response body itself straight to docker logs whenever a call fails. - server/src/emby/EmbyClient.ts: new private logErrorResponse, called from fetch() only when !res.ok. Reads the body via .clone() so the real caller still gets an untouched, fully readable response (a body can only be consumed once, and this had to not break either of the two very different ways this app's callers read one - fetchJson's .json(), proxyResponse's .text()/pipe). pathAndQuery is logged with api_key redacted - this can be called with Emby's own TranscodingUrl verbatim, which can carry one (same concern as CatalogService's logStreamRequest). - server/src/Logger.ts: new emby() method, same shape as party()/stream(). - server/test/embyClient.test.ts (new): body/status logged and readable by the real caller afterward, api_key redaction, and silence on a successful response (only failures are worth logging, same discipline as this app's existing logging). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| 4eab0323d6 |
Fix CI: npm test's glob pattern doesn't expand on Node 20
"tsx --test test/**/*.test.ts" worked locally (Node v22) and failed on the CI runner (node:20-alpine, Node v20) with: Could not find '.../server/test/**/*.test.ts' node --test only gained native glob-pattern CLI arguments around Node 21/22; on Node 20 the literal glob string is treated as a single filename and, unsurprisingly, not found. Same script, two Node majors, different behavior - not a shell/quoting issue (both runs go through tsx's own CLI, no shell glob expansion involved either way). Fixed by listing the four test files explicitly instead of relying on a glob whose CLI support turned out to vary across the exact two Node installs this project runs on (this dev machine, and node:20-alpine - the CI container and server/Dockerfile's runtime image, deliberately matched). engines.node is >=18, so relying on a 21/22-only feature was already a latent bug even before it showed up. Verified locally (all 25 tests still pass with the explicit list) - the actual node:20-alpine behavior will confirm on the next CI run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| fc678b313c |
Add a real server test suite and gate CI's Docker publish on it
- 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> |
|||
| 4a31ee741e |
Rewrite server + client in TypeScript, service-oriented server architecture
All checks were successful
Publier l'image Docker / publish (push) Successful in 18s
Full rewrite, requested directly. Two independent decisions since TypeScript can't compile itself away at runtime - "no build step" and "TypeScript" are mutually exclusive, not a small addition to the existing setup: - Server: plain tsc (no bundling need - Node resolves its own requires). server/dist mirrors server/src 1:1. - Client: esbuild (does need bundling) - picked over a heavier tool (Vite, webpack) since this app's whole client is a couple thousand lines; its built-in CSS-from-JS bundling and a `text` loader for importing an HTML template as a string cover everything needed via a ~30-line hand-written build script (client/build.mjs), not a config file for something built for much bigger apps. Both projects compile independently (no shared-types package - considered and rejected as more structural commitment than this app's size justifies; server/src/types.ts and client/src/types.ts have their own overlapping DTO copies instead). server/'s `npm run dev` uses tsx for a compile-free dev loop; `npm run build` (tsc) is what server/Dockerfile's build stage actually runs. client/'s `npm run watch` is esbuild's own watch mode. Server: one singleton service class per feature, generalizing the PartyManager/MusicQueueService pattern that already existed, rather than route handlers with logic inline: - AuthService (from embyAuth.js), CatalogService (NEW - previously ~15 route handlers' worth of Emby queries/DTO shaping inlined directly in index.js), PartyService (renamed from PartyManager), MusicQueueService (ported as-is, already service-shaped), SocketGateway (from socketHandler.js), EmbyClient (from embyProxy.js - low-level plumbing, not called a "service" since it has no state/feature of its own). - index.ts is now thin wiring only: every route reads the session, calls one service method, returns the result. - Real error-handling pass, not just a port: Express 4 doesn't catch a rejected Promise from an async handler on its own - a new asyncRoute() wrapper + a single error-handling middleware replace what used to be zero handling for a request that outright throws. CatalogService methods throw a CatalogFetchError (own HTTP status - getRawItem now preserves Emby's real 404 instead of a blanket 502) rather than touching res directly. Client: one folder per view (src/views/<name>/<Name>.ts + .html + .css) instead of one .js file with the markup inline as a template literal. A view's static shell moved to its .html (esbuild's text loader); per-item dynamic content (tile grids, episode/track lists) stays as template -literal-returning functions in the .ts, same as before. Genuinely shared CSS (tiles, the presence avatar, the series/album detail-header pattern, track-list rows) lives under src/styles/, imported by whichever views need it; src/styles/base.css holds the global primitives, imported once by app.ts. Deliberately not pushed to zero duplication/renaming shared classes across views for tidiness alone - not worth the risk in a change already this large. Player.ts keeps its pre-existing internal shape (the /party/:id dispatcher + the video renderer in one file, importing renderMusicPlayer from the sibling folder) - that pattern predates this rewrite and only needed types, not restructuring. Two real things the port itself caught, not just carried over: - A type bug: porting SocketGateway's "members" broadcast, reusing PartyService's PublicMember interface would have type-checked fine but was the wrong (larger) shape - the socket broadcast never included isPaused/positionTicks, only the REST response did. Caught by comparing against the original JS line by line, fixed with a dedicated SocketMemberDto type. - A real Express+TypeScript friction point: noUncheckedIndexedAccess (enabled in both tsconfigs - genuinely useful elsewhere, e.g. PartyService.advanceQueue's array indexing) makes every req.params.xxx read a type error, since Express's index-signature typing doesn't know a matched route's required :name segment is always present. Resolved once with a param(req, name) helper rather than 15 routes' worth of scattered assertions. Verified: both projects type-check cleanly in strict mode (tsc --noEmit - not just "esbuild didn't complain", which it wouldn't even with type errors present, since it transpiles without checking). Server: every unit/integration test from the JS version (party lifecycle logging, the music queue's timer/vote/pause behavior incl. real setTimeout auto-advance, the socket integration through SocketGateway+PartyService+ MusicQueueService together, the library-type filter, the playback -override forcing) re-run against the compiled server/dist output - identical results; the compiled server was actually started and confirmed listening cleanly. Client: since esbuild bundles everything into one app.js (no more individually-importable view modules), testing moved to driving the real compiled bundle through its actual boot -> login -> router -> view chain (mocked fetch/Socket.IO/hls.js, real clicks/hash navigation) - arguably more realistic than the old per-module approach. Confirmed this way: login, the sidebar, home's carousels, a video party (presence, host controls, leave), the full music hierarchy, a music party (presence, vote-skip, pause toggle, the inline browse-and-add panel), and the series page - all through client/dist's actual output, no thrown errors anywhere. Not verified: an actual `docker build` of the new multi-stage Dockerfile - this dev environment has no Docker at all. The build stage runs the exact npm run build commands already confirmed working locally, just inside node:20-alpine - the main remaining risk is something environment -specific, not the build commands themselves, but a real build is still the first thing to try after this lands. docs/architecture.md has the full writeup (new "TypeScript and a service-oriented architecture" section, plus a corrected top-level "Components" overview that had gone stale even before this change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| 5d535d18d8 |
Switch to Socket.IO; fix two real memory leaks
All checks were successful
Publier l'image Docker / publish (push) Successful in 10s
Requested: Socket.IO instead of raw WebSockets, and explicit attention
to memory leaks. Auditing for the second turned up two real, concrete
leaks, independent of the transport library:
1. Server-side: a raw `ws` server has no idea a client is gone unless
it gets a proper close frame - a sleeping laptop, a network drop, a
killed tab never fire 'close', so the PartyMember's socket
reference (and everything it closes over) sat in memory forever,
and the party was never cleaned up by anything else either
(removeIdleParties only looks at lastActivityUtc). Socket.IO's
built-in heartbeat (pingInterval/pingTimeout) detects and closes
these out on its own - a real, not just cosmetic, reason to prefer
it here.
2. Client-side: router.js only ever did currentRoot.innerHTML = ''
between routes, destroying DOM but never telling the outgoing
view's JS to stop. Leaving the player any way other than its own
"Quitter" button (a sidebar link, browser back) left the 3s
heartbeat setInterval, the realtime connection, and the hls.js
instance all running against a detached <video> indefinitely, for
as long as the tab stayed open.
Fixes:
- server/src/socketHandler.js replaces wsHandler.js: same protocol
(see its own top comment), over Socket.IO. Rooms (socket.join/
io.to) replace manually iterating party.members to broadcast.
Error event renamed to "errorMessage" - Socket.IO reserves "error".
- server/src/index.js: socket.io server instead of a raw
WebSocketServer, same-origin (no CORS config needed).
- client/js/party.js: socket.io-client (window.io, loaded via
<script> in index.html) instead of hand-rolled WebSocket + JSON
framing - also gets automatic reconnection for free.
- router.js: route() handlers may now return a cleanup function;
render() calls whatever the previous view returned before switching
to the next route, regardless of how navigation happened.
- player.js returns a cleanup that clears the heartbeat, destroys
hls, disconnects the socket, and removes its document-level click
listener (now named, not inline-anonymous, specifically so it CAN
be removed - an anonymous one added fresh per visit would itself
have been a slow leak even with everything else fixed). The
"Quitter" button is now just navigate('/home') - cleanup runs the
same way regardless of how you leave.
Verified for real (see session history): a real Socket.IO client
connected to a really-running local server end to end. Then, with
document.addEventListener/removeEventListener and setInterval/
clearInterval instrumented, drove the router through repeated "join a
party, then navigate away without clicking Quitter" cycles - adds and
removes matched exactly every time (3/3 listeners over three cycles,
2/2 intervals over two), nothing left pending.
docs/architecture.md has the full writeup under "Realtime transport:
Socket.IO, and two real memory leaks".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|||
| 6ff9aa29e2 |
Pivot to a standalone app - Emby plugin hit a hard wall
No plugin API exists for adding a button to Emby's player or item
detail page (confirmed by an Emby developer on their own forum, and
by research since). The only way to reach that would be patching
Emby's shipped web client files directly - outside the plugin,
breaks on every image update, needs manual reapplication. Not worth
the ongoing cost, per discussion.
New direction: a standalone app with its own player. Reads Emby's
catalog/streams directly from the browser (no plugin, no menu
integration fights); a small Node backend only coordinates party
membership and relays playback state over WebSocket - it never
touches a media byte. Auth is Emby-only, as chosen: the browser signs
in against Emby directly and this backend validates whatever token it
gets handed by asking Emby itself (GET /Users/{userId}), never seeing
a password.
Control model changed too: now that we own the player, host controls
playback and everyone else follows (event-driven play/pause/seek +
a 3s heartbeat, drift-corrected on the follower side) - the old
plugin's "everyone keeps their own controls, shared reference
position" was specifically a concession to nudging pre-existing Emby
app sessions we didn't control, which no longer applies.
- server/: Express + ws backend (party lifecycle, WebSocket protocol,
Emby token validation)
- client/: plain-JS/ES-modules frontend (login, search, item detail,
party player) - no build step
- Smoke-tested locally end to end against a stubbed Emby URL: server
starts, routing/auth-gating works, error handling degrades
gracefully (network failure, invalid token) - see the session
transcript for the actual browser test run
- docs/architecture.md rewritten around the new design, including why
the plugin was abandoned and a fresh "assumptions to verify" list
(CORS between this app's origin and Emby's is the biggest unknown)
- src/WatchParty/ (the Emby plugin) kept as-is for reference, no
longer the active direction
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|