8 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 8718175bae |
Fix playstate 400 "Value cannot be null": missing PlaySessionId
Real request logged after the last fix (MediaSourceId in the query
string) shipped: still a 400, now Emby's own "Value cannot be null.
(Parameter 'key')" - a bare .NET Dictionary null-key exception, not
the earlier problem (MediaSourceId was present and well-formed in the
logged request).
This exact message already burned this app once before, in
CatalogService.negotiateStreamTarget's hand-built fallback URL (see
its own long-standing TODO(verify) comment) - diagnosed there as Emby
indexing some internal state by a parameter the request hadn't
supplied. PlaySessionId is the standout remaining candidate: it's how
Emby/Jellyfin clients normally correlate a progress report with the
specific playback/transcode session it belongs to, and evidently
Emby's real Progress handler keys a lookup on it even though the
OpenAPI spec doesn't mark it required.
Fixed the same way MediaSourceId was: CatalogService
.negotiateStreamTarget already reads info.PlaySessionId from the same
PlaybackInfo response (it already used it in the hand-built fallback
URL) - its return type just needed to surface it too. PartyStreamService
now caches it alongside mediaSourceId and exposes both together from
one accessor, getCachedPlaybackIds(partyId), replacing the
round-1 getCachedMediaSourceId. PlaystateService.reportProgress and
index.ts's sweep both thread it through.
Not independently confirmed against a real server yet (no safe way to
fire a real, state-mutating test call without one) - flagged in
docs/architecture.md the same way every other real-server-dependent
guess in this app is, but grounded in this app's own prior confirmed
diagnosis of the identical error message rather than a fresh guess.
Updated catalogService.test's negotiateStreamTarget assertions to
cover playSessionId in both return branches, partyStreamService.test's
mocks/assertions for the new {mediaSourceId, playSessionId} cache
shape, and playstateService.test's request-shape and logging
assertions for the extra PlaySessionId param. Full typecheck/test/build
green (96/96 tests). docs/architecture.md and README.md updated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|||
| 55e8b8b378 |
Fix Emby playstate sync: wrong param shape, confirmed against real API
Checked the app's Emby-API assumptions directly against Emby's own
real OpenAPI spec (swagger.emby.media, pointed at a real server) after
a "watch status per user seems broken" report.
PlaystateService.reportProgress was wrong: POST
/Users/{UserId}/PlayingItems/{Id}/Progress does exist at that exact
path, but the spec declares PositionTicks/IsPaused as *query string*
parameters, not body fields - and a third parameter, MediaSourceId, is
*required* and wasn't being sent at all. The request body schema this
endpoint does list (Api.OnPlaybackProgress) covers unrelated fields
(PlaylistIndex/Shuffle/SleepTimerMode/...), none of these. So the
original guess sent PositionTicks/IsPaused somewhere Emby never reads
them, while omitting a parameter required just to accept the call -
almost certainly meaning every progress report was failing outright,
silently (best-effort by design, so nothing broke loudly).
Fixed: reportProgress now sends MediaSourceId/PositionTicks/IsPaused
as query parameters, no body at all. MediaSourceId comes from
PartyStreamService's own cache - CatalogService.negotiateStreamTarget
already resolves a real one for every party's shared stream
negotiation, so PartyStreamService now caches it alongside the target
path (negotiateStreamTarget's return type changed from a bare string
to {targetPath, mediaSourceId}) and exposes it read-only via a new
getCachedMediaSourceId(partyId) - deliberately passive, never triggers
a negotiation of its own, so a party where no one has started
playback yet this session simply has nothing to report; index.ts's
sweep skips it rather than paying for an extra PlaybackInfo round trip
every tick just for this.
While checking this, the other two flagged Emby-API assumptions (the
ffmpeg direct-stream URL, the /Playlists/{Id}/Items endpoint) were
re-checked against the same real spec too - both already correct, no
missing required parameter, consistent with them having been tried
for real successfully before.
Updated the 5 negotiateStreamTarget call sites in catalogService.test
to read the new {targetPath, mediaSourceId} shape, the 7
negotiateStreamTarget mocks in partyStreamService.test to return it
(plus 2 new tests: cache exposes mediaSourceId, a failed response
drops it too), rewrote playstateService.test's request-shape
assertions for query params instead of a body, and re-pointed
embyClient.test's generic body-encoding test at an arbitrary example
instead of implying it's still PlaystateService's real shape.
Verified: full typecheck/test/build (92/92 tests). docs/architecture.md
and README.md updated - the "Sync watch-party progress back to Emby"
section now documents the confirmed-wrong-then-fixed shape instead of
a standing TODO(verify), and README's "Not verified" paragraph moves
this (and the other two, now-confirmed) assumptions to "Resolved".
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>
|
|||
| b45a243e59 |
Browse Emby playlists and launch one as a pre-queued party
Queued right after the ffmpeg burn-in work, asked for directly: see
what's inside a playlist, and launch it - pre-queued with the whole
thing, the same "one click, the rest queues itself" shape starting from
an episode already gets for its series.
- server/src/services/CatalogService.ts: VISIBLE_COLLECTION_TYPES gains
'playlists' - Emby's /Views already returns that CollectionType once
the user has any, so it appears in the sidebar through the exact same
code every other library already uses, nothing else needed there. New
getPlaylistItemsRaw/getPlaylistItems (Emby's own /Playlists/{id}/Items,
mirroring getSeriesEpisodes/getAlbumTracks). New exported
splitPlaylistByMediaType: a playlist can mix movies/episodes and songs,
this app's party model can't - decides video vs music from the first
item's type and drops anything that doesn't match (logged when it
does) - pure, unit-tested on its own.
- server/src/index.ts: new GET /api/catalog/playlists/:playlistId/items
route. POST /api/parties gains a sibling early branch for
item.Type === 'Playlist' (a new createPartyFromPlaylist helper,
deliberately NOT merged into the existing single-item path, which
didn't need touching) - builds a video QueueItem[] exactly like the
episode-queue path already does, or seeds MusicQueueService.init +
addTrack per track for a music result, the same mechanism
POST /api/parties/:id/tracks already uses.
- client/src/tiles.ts: one more DRILL_DOWN entry (Playlist -> its own
page) - a playlist was never directly streamable, same as
Series/MusicArtist/MusicAlbum before it.
- client/src/api/embyApi.ts: getPlaylistItems(). No new API for
*launching* one - createParty(playlistId) already does everything,
same as clicking any other item; the server decides what it means.
- client/src/views/playlist/Playlist.{ts,html,css} (new): a playlist's
contents, read-only past the one "Lancer cette playlist" button -
deliberately no per-item start/add the way album tracks or episode
rows get (an Emby item carries no back-reference to "which playlist
contains me" the way an Episode's SeriesId does, so "start from item
N, queue the rest of the playlist" isn't cheaply reconstructable the
same way - not asked for here anyway). The launch button branches on
the created party's mediaType, same check Player.ts's own dispatcher
already makes.
- client/src/app.ts: registers /playlist/:id.
- server/test/catalogService.test.ts: the new methods' target
URL/mapping, plus splitPlaylistByMediaType's own tests (homogeneous
video, homogeneous music, mixed, empty).
- docs/architecture.md, README.md: design + the scope decision (no
per-item actions) spelled out.
Verified: full typecheck+test+build on both projects (62/62 server
tests). The new page driven through the same mocked-fetch + real-bundle
harness used throughout this session, in a real browser: header/meta/
item list render correctly from mocked catalog data (including the
sidebar showing "Playlists" once a mocked library declares that
CollectionType), and clicking "Lancer cette playlist" reaches
POST /api/parties with the right id and correctly routes into the video
player for a video result. Actual playback past that point still needs
a real Emby server - same standing caveat as the rest of this app.
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>
|
|||
| 137977893a |
Render subtitles client-side (<track>) instead of asking Emby to burn them in
Six rounds of real-server debugging (see docs/architecture.md's player-
track-selection section) found three different, unrelated reasons a
server-side burn-in could fail - missing fonts, a bitmap format libass
fundamentally can't burn, ffmpeg failing outright on an otherwise
unremarkable file - none of which this app's own request ever caused or
could have fixed, all downstream of the one thing every attempt had in
common: asking Emby for a real-time video re-encode. Not needing that
re-encode at all closes the whole class of failure instead of chasing
each one.
- server/src/services/CatalogService.ts: new proxySubtitle asks Emby to
convert the track to WebVTT (GET /Videos/{id}/{mediaSourceId}/
Subtitles/{index}/Stream.vtt - a real Emby endpoint their own web
client already uses for this) - entirely separate from the video
transcode. applyPlaybackOverrides lost its SubtitleStreamIndex/
SubtitleMethod=Encode branch - the video request no longer needs to
know a subtitle was picked at all.
- server/src/types.ts, client/src/types.ts: PlaybackTrackOption's flag
renamed supportsBurnIn -> isTextBased - same underlying fact (image vs
text codec), but the name shouldn't keep describing a mechanism this
app no longer uses.
- server/src/index.ts: new GET /api/catalog/subtitle/:itemId/:subtitleIndex
route, grouped with the other cookie-authed media-element routes (a
<track> can't send a bearer header either).
- client/src/api/embyApi.ts: new subtitleUrl(); streamUrl no longer
carries SubtitleStreamIndex at all.
- client/src/views/player/Player.ts: the subtitle <select> no longer
reloads the video (reloadWithCurrentOptions, still used for audio/
quality, never touches subtitles now). New applySubtitleTrack()
removes/re-adds a fresh <track> and sets video.textTracks[0].mode =
'showing' synchronously right after appending - deliberately not
waiting on the <track>'s own "load" event, confirmed against a real
browser that it doesn't fire reliably at all (a known cross-browser
quirk), which would have left a subtitle silently never shown - the
exact symptom this whole saga started from. Re-applied on every video
(re)load so an audio/quality change (which does still reload the
video) doesn't drop whatever subtitle was showing.
- server/test/catalogService.test.ts: updated/new cases for
proxySubtitle and the renamed isTextBased flag; removed the test for
the now-deleted subtitle-forcing behavior.
Verified against the real compiled bundle: picking a text track adds one
<track> with the right URL and flips it to showing; "Aucun" removes it;
an image-coded track is listed but disabled; changing audio reloads the
video but the subtitle survives it, same element, still showing.
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> |