Commit graph

29 commits

Author SHA1 Message Date
f5a941525f Rework the player's bottom bar and add a real fullscreen toggle
All checks were successful
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Successful in 7s
Asked for directly - a UI rework of the video player in both normal and
fullscreen mode, with one concrete requirement: a viewer in fullscreen
still needs to see the pause/resume request buttons. Built a mockup
first and iterated it through feedback before touching any code - it
ended up covering three things, not the two first asked for.

The actual fullscreen bug, found while looking into the request: this
app never had its own fullscreen button - the only way in was the
browser's native affordance on the bare <video> itself (double-click,
or the host's own native <video controls> fullscreen button). Native
per-element fullscreen only renders that element's own subtree, and
every overlay - top bar, presence, and critically the request toolbar -
is a sibling of <video>, so all of it vanished the instant fullscreen
engaged. A follower in fullscreen had no way to reach the host at all.

Fix: a new #fullscreenBtn calls playerWrap.requestFullscreen() - the
whole player, video AND overlay chrome - never video.requestFullscreen().
The native per-video path is still reachable (nothing removed there -
the host keeps native play/pause/seek/volume exactly as before), so a
document-level fullscreenchange listener watches for
document.fullscreenElement === video specifically and corrects it:
exit, then re-enter on playerWrap instead.

Bottom bar rework: three previously-independent floating pieces (a
centered banner/notice column, a request toolbar cornered bottom-right,
an "Épisode suivant" text link in the top bar) become one #bottomBar
with three zones - left shows exactly one of #nextBtn (host, now a real
accent-green pill with a skip-next icon) or #requestToolbar (follower,
now icon + explicit "Demander pause"/"Demander reprise" labels) since
the two are mutually exclusive by construction; center is a single
#ambientNotice line (casting takes priority over "L'hôte contrôle la
lecture" - never both at once); right is the fullscreen toggle, for
everyone. The transient request banner stays independent near the top,
not folded into the persistent bar, so it still reads as a toast.

Also added #castingScreen: a plain icon + "Diffusion sur le Chromecast"
covering the video area while casting, instead of leaving the local
<video>'s last frame sitting there paused with no explanation.

Verified: full typecheck/build on the client (pure markup/CSS/TS, no
server changes). Checked in a real browser against the actual compiled
app.css/bundle - injected the new markup for the host, follower, and
casting views and confirmed each renders as designed; caught and fixed
a real sizing bug this way (.fsBtn grew to 32px instead of its declared
30px - a flex item's default min-width:auto let button's own base
padding win over the smaller explicit width - fixed with an explicit
padding:0). Not verified: the real Fullscreen API transition itself
needs a user gesture this sandbox can't exercise - worth trying once
deployed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 11:06:44 +02:00
5a1bc8d932 Fix the listen-party queue's layout and its missing album art
All checks were successful
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Successful in 12s
Reported directly: the music bar's queue popup looked broken, and the
listen-party card on the home page never showed an image. Put together a
mockup first (this app's own theme tokens, not a generic one) to confirm
the redesign direction before touching any code, since the queue side
was a real UI rework, not a one-line fix.

The layout bug: MusicBar.ts's queue rows were reusing views/album's
shared .trackRow (styles/trackList.css) - a CSS grid with 4 fixed
columns sized for THAT page's own row shape (track number, name,
duration, a button). The queue never had a track number, and rendered
completely different content in that same slot, so the track's own name
- the first child - landed in the 28px column meant for a one-or-two-
digit index, crushed to its first couple of characters, while the
artist/album text filled the wide column meant for the name instead.
Fixed with a dedicated .queueRow (flex, not grid): album art, a
truncating name+artist/album/added-by info column, duration on the
right.

The missing images: confirmed directly against the real Emby server
(three sampled tracks, Fields=AlbumId,ImageTags) that an individual
Audio item essentially never carries its own Primary image - every one
tested answered 500, not even a clean 404 - while the same tracks'
parent albums mostly did. Every place that built an image URL from a
raw EmbyItem was independently reconstructing /api/catalog/image/{id}
by hand (CatalogService.toItemDto, and three separate spots in
index.ts's party-creation routes bypassing toItemDto entirely) - all
now funnel through one new CatalogService.imageUrlFor(item), which
points a track at its AlbumId instead of its own id when one's known.
EmbyItem gained AlbumId; every Fields param listing Audio items now
requests it.

While fixing the now-working image, the home page's party card turned
out to be the wrong shape for music too: a fixed 48x72 portrait box,
correct for a movie/show poster, forcing a hard crop on an album's
square cover. Video parties are untouched; a music party's card now
gets a 56x56 square box instead, toggled from LobbyPartyDto.mediaType.

Tests: imageUrlFor (movie/episode unaffected, a track with an AlbumId
falls back to it, a track with neither still falls back to its own id
rather than a broken URL). Full typecheck/test/build clean on both
projects (118/118 server tests). Verified visually against the actual
compiled app.css (not just the mockup) - injected real markup using the
new classes into a running instance and confirmed the queue rows and
both card shapes render as designed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 10:34:39 +02:00
b4c81d820c Cast a party's video to a Chromecast
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 16s
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>
2026-09-15 10:13:12 +02:00
b167e321e7 Replace PlayingItems/Progress with direct UserData writes
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 13s
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>
2026-09-14 23:53:59 +02:00
9b3e051385 Fix slow catalog browsing: images were never cacheable
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 14s
Reported directly ("l'api du contenu des catalogues est hyper lente").
Timed CatalogService's own listing calls directly against the real
server - a 300-item library listing came back in well under half a
second, so that wasn't it. Confirmed the real cost instead:
EmbyClient.proxyResponse (what every thumbnail goes through, via
CatalogService.proxyImage) was silently dropping every caching signal
Emby sent. Confirmed directly against the real server that an image
response already carries Cache-Control: public and a real ETag, but
this method only ever forwarded content-type/content-range/
accept-ranges. With no validator and no cache policy ever reaching the
browser, every one of potentially 300+ tiles in a library grid had to
be re-downloaded in full, from scratch, on every single visit.

Fixed both directions:
- EmbyClient.fetch now also accepts ifNoneMatch/ifModifiedSince, sent
  as If-None-Match/If-Modified-Since - and a 304 (the whole point of
  sending those) is explicitly excluded from the failure-logging path,
  since !res.ok is true for it but it's Emby correctly saying
  "unchanged", not an error.
- proxyResponse reads the browser's own If-None-Match/If-Modified-Since
  request headers and forwards them to Emby, and forwards
  cache-control/etag/last-modified back from Emby's response,
  alongside the existing content-range/accept-ranges pair.

Confirmed end to end against the real server, not just reasoned about:
a plain image GET comes back 200 with Cache-Control: public + a real
ETag; the same request repeated with that ETag as If-None-Match comes
back a real 304 with a zero-byte body. Harmless for everything else
this method proxies (video/audio segments, playlists, subtitles) - it
only forwards whatever Emby itself decided, never invents a policy.

New EmbyClient.proxyResponse tests (never exercised directly before -
every existing test that touches it mocks it away entirely) cover
forwarding the cache headers on a real bodied response, forwarding the
browser's conditional headers to Emby and relaying a real 304 with no
body, and that a response with neither header doesn't choke or invent
one. New EmbyClient.fetch tests cover a 304 not hitting the failure
log and the two new headers being sent only when given. Full
typecheck/test/build green (101/101 tests). docs/architecture.md and
README.md updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 23:18:58 +02:00
8718175bae Fix playstate 400 "Value cannot be null": missing PlaySessionId
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 15s
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>
2026-09-14 22:56:54 +02:00
55e8b8b378 Fix Emby playstate sync: wrong param shape, confirmed against real API
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 12s
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>
2026-09-14 21:11:32 +02:00
4efcd67fb3 Sync text subtitle choice too, and fix an empty audio option
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 14s
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>
2026-09-14 20:41:02 +02:00
f78b2f1408 One shared Emby stream per party, not one per viewer
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 20s
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>
2026-09-14 20:05:34 +02:00
11f003d269 Sync watch-party progress back to Emby (PlaystateService)
All checks were successful
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Successful in 16s
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>
2026-09-14 19:21:16 +02:00
b45a243e59 Browse Emby playlists and launch one as a pre-queued party
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 16s
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>
2026-09-14 19:04:31 +02:00
26da8fc5dc Self-hosted ffmpeg pipeline for PGS (image) subtitle burn-in
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 30s
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>
2026-09-14 18:05:08 +02:00
daab755ce9 Device detection: confirm before disconnecting another device
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 18s
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>
2026-09-14 17:32:21 +02:00
c14d0bc08b Replace avatar-click with a request toolbar; add "request resume"
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 17s
Asked for directly: a visible bottom toolbar for non-host members
instead of clicking your own small avatar circle (never a very
discoverable affordance), and a second request alongside pause -
"please resume".

- server/src/types.ts, server/src/realtime/SocketGateway.ts: new
  requestResume/resumeRequested, byte-for-byte the same shape and
  handler pattern as requestPause/pauseRequested, independently
  throttled (its own lastResumeRequestUtc - not a shared budget with
  pause). Kept as a plain broadcast signal, same as pause always was -
  no accept/deny state tracked server-side, the host sees the same
  banner/avatar highlight as everyone else and acts with their own
  existing controls.
- client/src/api/party.ts, client/src/types.ts: requestResume() on
  PartyConnection, onResumeRequested callback, a ResumeRequestedMsg type
  (same shape as PauseRequestedMsg, kept separate so a callback's own
  signature says which request it's for).
- client/src/views/player/Player.{html,css,ts}: the avatar is presence-
  only now (no click handler, no "- demander une pause" title) - a new
  .bottomOverlay wraps the request banner, the follower notice, and a
  new .requestToolbar (Demander une pause / Demander une reprise) as one
  flex column, same "stack in normal flow instead of hand-picking a
  pixel offset per box" fix .topOverlay already needed. Pause and resume
  share one showRequestBanner() - they only ever differed by which word
  appears in the banner text. Renamed the shared CSS classes
  (avatarPauseRequested/avatarPauseBadge -> avatarActionRequested/
  avatarRequestBadge) since they're no longer pause-specific.
- server/test/socketGateway.test.ts: new case confirming requestResume's
  throttle is independent from requestPause's own.

Verified against the real compiled bundle: the toolbar shows for a
follower and stays hidden for the host; each button's own client-side
cooldown collapses a rapid double-click to one emission; a pushed
resumeRequested shows the banner with the right text and highlights the
right avatar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 17:09:01 +02:00
b2310216ce Fix settings menu visibility bug; make the music player a persistent bar
Some checks failed
Publier l'image Docker / publish (push) Has been skipped
Publier l'image Docker / test (push) Failing after 8s
Fix: player settings menu stuck permanently open
----------------------------------------------
.hide { display: none; } and Player.css's .settingsPanel { display: flex;
... } are both single-class selectors - equal specificity - so whichever
one esbuild happened to bundle later into app.css won the `display`
property on an element carrying both classes. Player.css's own
display:flex always landed after base.css's .hide, so #settingsPanel
showed regardless of the hide class ever being toggled - the toggle logic
itself was correct the whole time, it just had no visible effect.

Fixed at the utility class, not the one call site: `.hide` now uses
`display: none !important`, which wins a same-specificity tie regardless
of bundling order - closes off the whole bug class, since several other
components already set their own `display` for a visible state.

Feature: music player is now a persistent bottom bar, not a page
------------------------------------------------------------------
The music player was its own route (/party/:id -> views/musicPlayer/),
which meant navigating anywhere else - the sidebar, an album to queue up
more tracks - tore the whole thing down (router.ts calls a route's
cleanup, which used to call leaveParty, on every hash change). No way to
browse and listen at the same time.

- client/src/musicBar/MusicBar.ts (new): a Shell-level singleton, mounted
  once into a #musicBar element that's a sibling of #mainContent, never
  touched by route changes. Renders as a fixed bottom bar. Exposes
  initMusicBar/joinMusicParty/leaveMusicParty instead of the router's
  render()-returns-cleanup pattern, since it never registers as a route.
- views/player/Player.ts's dispatcher: a music party no longer renders a
  page - it calls joinMusicParty() and bounces back to /home (a no-op for
  every reachable call site except album's). A video party now calls
  leaveMusicParty() first (harmless no-op if nothing's active) - starting
  or joining a video watch party always leaves the listen party, since
  every party-join call site already funnels through this one dispatcher.
- views/album/Album.ts: the last per-track "start a party" UI now that
  the music player's own inline browse panel is gone (browsing just
  means navigating there normally). Each track's button starts a new
  party or adds to whichever one is already active, decided by
  getActiveMusicPartyId() at CLICK time (not render time) so the action
  taken is always correct even if the label is momentarily stale; every
  button is relabeled in place after a successful click. Starting a
  party from here no longer navigates away at all.
- views/musicPlayer/ deleted entirely (MusicPlayer.ts/.html/.css) -
  superseded by musicBar/.
- Comment updates in global.d.ts/presence.css/trackList.css/party.ts/
  types.ts that named the old module by path.

Verified both fixes by rebuilding and driving the real compiled bundle
through a local static server, with fetch/window.io/window.Hls mocked:
starting a party from an album track leaves the sidebar/page in place and
relabels the other tracks; adding a track posts to the already-active
party's id; navigating into a video party hides the bar, disconnects its
socket, and posts a real leave for the music party before the video
player renders; the settings gear opens/closes correctly for the host in
that same run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 10:43:12 +02:00
fc678b313c Add a real server test suite and gate CI's Docker publish on it
Some checks failed
Publier l'image Docker / test (push) Failing after 11s
Publier l'image Docker / publish (push) Has been skipped
- 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>
2026-09-14 09:57:47 +02:00
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>
2026-09-14 09:38:14 +02:00
efaa7f55f0 Add music: Artist/Album/Track browsing + participative listen-party queue
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
The last deferred piece from the original spec. Chosen permission model
(asked, not assumed): anyone can add a track to the shared queue any time;
anyone can pause/resume directly; skipping the current track needs a
strict majority vote of currently connected members. No host for
playback - a genuinely different shape of shared state than video's "host
drives, everyone follows", which is why it's a separate service
(server/src/musicQueueService.js, MusicQueueService) rather than more
branches in partyManager.js/socketHandler.js's existing video-shaped logic.

Server:
- musicQueueService.js: owns a music party's queue/current-track/pause/
  vote-skip state. Server-authoritative timing (no client is "the host"
  reporting position) - a wall-clock startedUtc plus the track's known
  duration is the only state; auto-advance is one setTimeout scheduled
  for exactly when that runs out, not any client reporting "it ended"
  (which could race across several players finishing near-simultaneously
  with no host to arbitrate). attachIo(io, onTrackChanged), called once
  at startup like PartyManager's own wiring, lets it broadcast
  "queueUpdated" from inside that timer's callback, which has no
  socket/request in scope at all.
- The timer is the one new leak surface this adds, given how much this
  project has already had to fix real leaks - destroy(party) clears it,
  wired into both of partyManager.js's existing destruction paths
  (leave's last-member case, removeIdleParties' sweep).
- Party gained mediaType: 'video'|'music' (inferred server-side from the
  clicked item's Emby Type - the client never has to know or say which).
- Three new catalog endpoints (Artists -> Albums -> Tracks), same
  reasoning as the existing Shows -> Seasons -> Episodes: a real Emby
  endpoint per level, not generic recursive /Items guessing.
- Audio streams through the *same* /master.m3u8 route as video (Emby's
  PlaybackInfo negotiation isn't video-specific) - the one difference is
  the hand-built-fallback path prefix (/Audio/ vs /Videos/), which the
  client tags via a mediaType=audio query hint rather than costing an
  extra Emby round trip to detect server-side.
- Adding a track is REST (POST /api/parties/:partyId/tracks), not a
  socket event like voteSkip/musicPlaystate - it needs to work from the
  player's inline browse-and-add panel regardless of realtime-connection
  specifics, and MusicQueueService's own broadcast reaches everyone
  connected either way.

Client:
- player.js's renderPartyPlayer is now a small dispatcher: fetches the
  party once, then delegates to renderVideoPlayer or the new
  renderMusicPlayer (views/musicPlayer.js) based on mediaType. Can't be
  async despite awaiting that fetch - route()'s contract needs its
  cleanup function back synchronously (router.js's `typeof cleanup ===
  'function'` check would silently skip a Promise, reintroducing the
  exact leak class already fixed once) - solved with a mutable box
  returned immediately, filled in once the fetch resolves.
- musicPlayer.js: not full-viewport/theater-mode like the video player
  (no image to declutter around, sidebar stays up) - which is what makes
  its own inline Artist -> Album -> Track browser work for "add tracks
  while listening" without navigating away and losing player state or
  threading party context through query params across three route levels.
- New views/artist.js, views/album.js (library.js's music branch renders
  an artist grid instead of the old placeholder). tiles.js's DRILL_DOWN
  table generalizes the old single seriesHref special-case now that
  there are three drill-down targets, not two.
- party.js: addTrackToQueue (REST) alongside voteSkip/setMusicPaused
  (socket) on the existing connectParty() connection object.

Real bug found while testing, not just the mock's fault: applyMusicState
used to update the queue list/vote button *after* the current-track
branch, which calls loadTrack() - and loadTrack()'s hls.js setup can
throw. Since JS doesn't roll back earlier statements when a later one
throws but does abort everything after it, an exception there took the
unrelated queue list and vote tally down with it, and on the first render
escaped all the way to the dispatcher's catch, which alerts and bounces
back to /home - discarding the whole view over what's really just "this
one track's audio failed to load". Fixed by reordering (queue/vote/icon
updates - which can't fail - before the track-loading branch, mirroring
how player.js's onItemChanged already updates title/next-button before
calling loadVideo) and wrapping loadTrack's hls.js setup in its own
try/catch so a failure there logs and stops instead of propagating.

Verified server-side directly against the real shipped modules (not
reimplemented mocks): 18 checks against musicQueueService.js alone (auto
-start, queueing, vote-skip majority, pause/resume elapsed-time math, a
real setTimeout auto-advance, timer cleanup on destroy - instrumented
setTimeout/clearTimeout counting, same technique as the earlier client
-side leak tests) and 7 more through socketHandler.js + partyManager.js
together (no-ops on a video party, a non-host pausing/resuming, a
two-vote majority skip, the resulting broadcast really reaching the
room). Client-side against the real local server with mocked fetch/Hls/
io: the full browsing chain, party creation, the dispatcher choosing the
right player (and still choosing video when mediaType is absent - back
-compat), now-playing/vote-tally/queue rendering and updates including a
live track change, play/pause and vote-skip emitting the right events,
the inline browse-and-add panel POSTing the right payload, and the crash
fix re-confirmed fixed via the same broken scenario that first found it.

Not verified: the real Emby round trip for any of this (Artists/albums
-tracks response shapes, audio PlaybackInfo/TranscodingUrl behavior,
actual playback quality) - same limit as everything else in this project
tested without live Emby access.

docs/architecture.md has the full writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 01:49:22 +02:00
0c439259ac Add server-side logging of the party lifecycle
All checks were successful
Publier l'image Docker / publish (push) Successful in 6s
Requested now that the app is actually running on real infrastructure:
visibility from docker logs/journalctl into what's happening (parties
created/destroyed, who joined/left, play/pause, pause requests) without
needing to reproduce a bug through the UI first.

server/src/logger.js: a deliberately tiny partyLog(message) - plain
console.log, prefixed [watchparty:party], ISO timestamp - plus a
who(userId, userName) formatter shared by every call site. No logging
library; this app runs as a single container, not behind a log
aggregator, so a consistent grep-able prefix is what actually helps here.

Covers the party's business lifecycle end to end, logged at the source so
every caller (REST and socket) is covered without repeating the check:
- partyManager.js: create, join, leave (also covers a host handoff or the
  party being destroyed, both fall out of leave), advanceQueue,
  removeIdleParties.
- socketHandler.js: playstate and requestPause, since those two mutate
  member state directly rather than through a PartyManager method.

The one thing that needed real care: the host's player re-sends its
CURRENT play/pause state every 3 seconds regardless of whether anything
changed (the heartbeat), on top of firing per actual play/pause/seeked
event. Logging every "playstate" message as it arrives would mean one log
line every 3 seconds per active party, forever. The handler instead
compares incoming isPaused against the member's previous value and only
logs on an actual flip - so a party generates exactly one line per real
pause or resume, never one per heartbeat tick.

Verified directly against the real shipped modules, not reimplemented
mocks: a scripted create/join/advanceQueue/leave/idle-sweep sequence
against the real partyManager.js produced exactly the expected line at
every step; a fake io/socket pair driving the real socketHandler.js
through start -> 5 unchanged heartbeats -> pause -> 3 unchanged
heartbeats -> resume logged exactly 3 playstate lines (not 9) at exactly
the right points, and a throttled repeat requestPause produced no second
line - all assertions passed, counted programmatically.

docs/architecture.md has the full writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:53:55 +02:00
85bcf946b4 Filter sidebar to films/musique/clips vidéos/TV, fix music videos not being playable
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
server/src/index.js: GET /api/catalog/libraries now filters Emby's /Views
response to VISIBLE_COLLECTION_TYPES (movies/tvshows/music/musicvideos)
before returning it. Emby's /Views can return all sorts of things this app
has no UI for at all - boxset collections, playlists, live TV, photos,
books, a mixed-content folder with no CollectionType - none of which
should ever have shown up in the sidebar. Filtering once at the source
means every consumer (the sidebar, home.js's per-library carousels, direct
navigation to /library/:id) is restricted the same way without repeating
the check in each place; a stale link to a now-hidden library's id just
falls into library.js's existing "Bibliothèque introuvable" state, since
it looks the id up in this same filtered list.

Found while making musicvideos a real visible category: its items are
Emby's own 'MusicVideo' type (distinct from the generic 'Video'), which
WATCH_PARTY_TYPES didn't include - every tile in a music-videos library
would have rendered with no "Lancer une watch party" button at all, only
"Voir sur Emby". Added it.

Also fixed two stale doc/comment references while touching this code:
the sidebar hasn't shown per-type icons since the earlier "no emoji"
request (plain text links only), and views/home.js's party carousel has
used the live lobby Socket.IO subscription instead of a GET /api/parties
fetch since that endpoint was removed - both docs/architecture.md and an
inline comment still described the old behavior.

Verified: a plain filter/map unit test confirms exactly
movies/tvshows/music/musicvideos survive against a mock /Views response
seeded with one of every excluded type (photos/livetv/boxsets/playlists/
books/homevideos/mixed/no-type-at-all); WATCH_PARTY_TYPES.has('MusicVideo')
checked directly; and mounting shell.js + library.js against a mocked
already-filtered libraries response renders exactly the 4 expected sidebar
links and a working "Lancer une watch party" button on a MusicVideo tile.

docs/architecture.md has the full writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:40:07 +02:00
669873e41c Presence overlay (avatars) + "request pause" in the player
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Two features requested together: a semi-transparent overlay showing who's
in the party, and a way for a non-host to ask the host to pause.

- server/src/socketHandler.js: new "requestPause" event (anyone, throttled
  5s per member server-side via member.lastPauseRequestUtc - the real
  guard, independent of whatever the client does) broadcasts
  "pauseRequested" {userId, userName} to the whole party room, not just the
  host, so everyone sees why playback is about to pause.
- client/js/party.js: connectParty() grows an onPauseRequested callback and
  the returned connection a requestPause() method, same shape as the
  existing next()/sendPlaystate().
- client/js/views/player.js: renderMembers() now also rebuilds a
  #presenceOverlay of one circle per member (initials from userName,
  colorForName() picks a deterministic background from a small palette by
  hashing the name - same person, same color, no server-side assignment
  needed). "Which circle is mine" compares userName against the auth
  singleton embyApi.js already populates, matching how the rest of the
  client avoids keying UI logic on userId directly. Your own circle, only
  when you're not host, is clickable and calls requestPause() (client-side
  cooldown too, purely to avoid a click that would visibly do nothing - the
  server-side one is what actually matters). Receiving pauseRequested shows
  a short auto-clearing banner and pulses the matching circle for everyone,
  including the requester and the host.
- client/css/app.css: .presenceOverlay/.avatar (bottom-left, opacity .75 is
  the "semi transparence" itself) and the clickable/sent/pulse states,
  .pauseRequestBanner.

Verified with mocked members/pauseRequested Socket.IO events against the
real running local server (see session history): initials/colors render
correctly, the host's own circle never gets the clickable affordance,
rapid repeat clicks collapse to exactly one requestPause emission (three
synchronous clicks -> one new emitted event), a pauseRequested event
banners and pulses the right circle. Re-checked the pre-existing host path
(becomeHost, settings menu, next-episode button) still works unchanged -
this pass only touched renderMembers() and init()'s connectParty() call.

docs/architecture.md has the full writeup. Also fixed two roadmap
checkboxes in README.md that should have been checked off by the previous
commit (live lobby/host handoff/no join codes) but were missed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:05:40 +02:00
b6019bd211 Live lobby, host handoff reflected live, remove join codes, fix player scroll
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Three requested changes plus one found-in-passing bug fix:

1. Home page's "watch parties en cours" is now a live Socket.IO
   subscription (connectLobby -> "lobby" room), not a one-shot fetch -
   updates the instant anything changes anywhere (create/join/leave).
   Host name + member count come from the snapshot; elapsed time ticks
   purely client-side from a createdUtc timestamp (no point pushing a
   recomputed duration every second for every viewer). home.js returns
   this subscription from its router cleanup, same leak-prevention
   pattern as the player's socket.

2. Host handoff is now live end to end, not just correct in the data
   model. PartyManager.leave already promoted another member and
   destroyed an empty party (that part predates this change) - what
   was missing was any way for an already-open follower player to find
   out it became host. applyLeave (shared by the socket leave/
   disconnect handlers and the REST leave endpoint, so this and the
   lobby broadcast only live in one place) now emits a personal
   "hostChanged" to the new host's own socket; player.js's becomeHost()
   unlocks controls live. becomeHost is written to tolerate being
   called more than once safely (addEventListener no-ops on a repeat
   identical listener; the heartbeat setInterval is explicitly guarded
   since it has no such idempotence - calling it twice would leak the
   first interval, the exact bug class the previous pass fixed).

3. No more join codes - PartyManager no longer generates or tracks
   one. The live lobby list is the only discovery mechanism now; a
   code copied out of band doesn't add anything on top of a list
   that's already always current. POST /api/parties/:partyId/join
   replaces :joinCode; the "Rejoindre avec un code" UI is gone.

Found while touching this: the home page's "Rejoindre" button used to
navigate to the player WITHOUT ever calling the join endpoint first -
it loaded fine (GET /api/parties/:id never checked membership) but the
realtime "join" was silently rejected server-side (not an actual
member), so sync never worked for anyone joining this way. Fixed to
await joinParty() before navigating, matching how the library grid's
tiles already did it.

Also: .playerWrap was width:100%/height:100vh inside .mainContent's own
padding, so the two stacked and made the page taller than the
viewport - an outer scrollbar despite the video already being full-
height. Changed to position:fixed/inset:0 so it fills the real
viewport regardless of ancestor padding.

Verified live against a real running server (see session history):
lobby subscription renders host/member-count/ticking-elapsed correctly
from a faked "parties" push (createdUtc 2:05 in the past rendered
"2:06" after ~1s more, proving the client-side tick is real, not
static); the join-then-navigate fix calls the join endpoint before
navigating; .playerWrap computes to position:fixed with all four
insets at 0.

docs/architecture.md has the full writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:47:12 +02:00
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>
2026-09-13 23:07:51 +02:00
f563f5b0a2 Emby-style UI: sidebar, carousels, series drill-down, episode queue, player tracks
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Big UI pass towards looking/behaving like Emby. Deferred: music (needs
its own player + a real-time collaborative playlist - different enough
subsystem to be its own pass; sidebar/routing already treat it as a
first-class collectionType, page just says "not yet" for now).

Layout:
- client/js/shell.js: persistent sidebar (one entry per Emby library,
  from GET /api/catalog/libraries which now also returns
  collectionType) + a #mainContent the router swaps on navigation.
  router.js made idempotent (start() safe to call more than once) so
  a login -> shell boot can happen twice in one page lifetime (logout
  then back in) without piling up duplicate hashchange listeners.
- app.js's boot() renders either the bare login screen or the full
  shell depending on session state; login.js calls it again on
  success instead of navigate() (no router running yet at that point).

Browsing, by collection type:
- views/home.js (route /home): a carousel of every active party
  server-wide, a join-by-code fallback, then one "recently added"
  carousel per non-music library (GET /api/catalog/libraries/:id/recent,
  Emby's Items/Latest).
- views/library.js (route /library/:id): dispatches on collectionType -
  movies/tvshows get the tile grid (Series tiles link into /series/:id
  instead of starting a party directly), music gets an honest
  placeholder.
- views/series.js (route /series/:id): season tabs + episode list
  (new GET /api/catalog/series/:id/seasons and .../episodes, Emby's
  /Shows/{id}/Seasons and /Episodes).
- tiles.js: poster-tile rendering shared by home.js and library.js so
  they can't drift apart.

Episode queue / "next episode":
- POST /api/parties now builds a queue when starting from an episode
  (fetches the rest of the series, from that episode onward) -
  partyManager.js gained queue/queueIndex/advanceQueue.
- New WS message {type:"next"} (host-only) advances the queue and
  broadcasts {type:"itemChanged", itemId, itemName, ...}; every client
  reloads the player against the new item. player.js shows "Épisode
  suivant" when hasNext, and auto-sends next() on the video's `ended`
  event.

Player: language/subtitles/quality:
- New GET /api/catalog/item/:id/playback-options exposes PlaybackInfo's
  MediaStreams as audio/subtitle track lists.
- master.m3u8 now forwards AudioStreamIndex/SubtitleStreamIndex/
  MaxStreamingBitrate to PlaybackInfo; player.js has three <select>s
  (host-only) that reload the stream at the same position on change.
  Subtitles end up burned into the transcode (via SubtitleStreamIndex),
  not a toggleable track - simpler than sidecar WebVTT for this pass.
  "Quality" is a MaxStreamingBitrate ceiling, not true adaptive bitrate.

Verified by driving a browser against mocked /api/* responses (see
session history): sidebar renders/highlights correctly, home carousels
render (parties + one row per non-music library, music correctly
skipped), library grid dispatch works, series page season-tab
switching + episode list render and the season/episode navigation
flow works end to end. Real Emby response shapes for all of this
(Views' CollectionType, Seasons/Episodes, Items/Latest, PlaybackInfo's
MediaStreams) and the queue/track-picker protocol are NOT yet
confirmed against a live server - see docs/architecture.md.

Removed client/js/views/browse.js, superseded by home.js + library.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:44:49 +02:00
b6818655b1 Proxy all Emby access through our own backend; drop server-url field; use Emby's real theme colors
All checks were successful
Publier l'image Docker / publish (push) Successful in 6s
Three changes requested:

1. Server proxies Emby, browser never talks to it directly. Login is
   now POST /api/auth/login {username,password} on our own backend;
   the backend calls Emby's AuthenticateByName server-side and mints
   its own opaque session (httpOnly cookie), storing
   {embyToken,userId,userName} in memory. The browser never sees or
   holds a real Emby access token. Every other call - search, item
   details, images, HLS streaming, party actions - goes to our own
   /api/* origin, authenticated by that cookie; server/src/embyProxy.js
   forwards to Emby server-side and streams the response back
   (including a playlist-rewrite pass for HLS .m3u8 so hls.js never
   gets handed a URL pointing at Emby directly). This also sidesteps
   the CORS problem the previous direct-to-Emby design had.

2. EMBY_SERVER_URL is backend-only config (.env.example); the login
   form no longer asks for a server address at all.

3. client/css/app.css now uses colors read directly off the live Emby
   server's own dark theme (--theme-primary-color-* etc, fetched via a
   JS probe against the running client, not guessed): accent green
   #52b54b/#6ccf65, near-black background #151216.

Tested for real: ran the server locally against the actual
EMBY_SERVER_URL and submitted intentionally-wrong credentials through
the UI - confirmed the full round trip (browser -> our backend -> real
Emby -> real 401 -> our error message) with no CORS issue, since it's
all server-to-server now. Login itself hasn't been exercised with real
credentials, so catalog/streaming remain unverified - see
docs/architecture.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:36:26 +02:00
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>
2026-09-13 20:48:55 +02:00
6f22f72532 Integrate Watch Party into Emby's own UI instead of a standalone app
Per user feedback: no separate control panel, no chat. Found that
PluginPageInfo has EnableInUserMenu/EnableInMainMenu (confirmed via
reflection on the real MediaBrowser.Model assembly, plus an Emby
community thread showing another plugin using EnableInUserMenu=true)
- a real, if barely documented, way to surface a plugin page to
regular users, not just admins, through the exact same
IHasWebPages/GetPages() mechanism as the admin settings page.

- Plugin.cs now registers a second page (partyPage.html) with
  EnableInUserMenu=true + EnableInMainMenu=true
- partyPage.html replaces the old standalone control panel: no login
  form, it runs inside the already-authenticated Emby web client and
  calls the REST API via the global ApiClient
- Removed web/control-panel/ (the standalone app, its own login flow,
  and the custom GET /WatchParty/app* static file routes in
  WatchPartyService.cs) - no longer needed
- docs/architecture.md and README.md updated: the new mechanism, and
  the honest limitation that comes with it (reaches Web/Electron
  clients, not official mobile/TV apps - those allowlist plugin pages
  client-side)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:44:31 +02:00
db815df7d5 Fix plugin to build clean against real Emby 4.9.1.90 SDK
Installed .NET 8 SDK (per-user, winget needed admin elevation this
machine doesn't have) and restored the real MediaBrowser.Server.Core /
MediaBrowser.Common packages, then fixed everything the compiler and
a bit of reflection surfaced:

- PlaybackProgressEventArgs/PlaybackStopEventArgs are in
  MediaBrowser.Controller.Library, not ...Session
- IAuthorizationContext/AuthorizationInfo are in MediaBrowser.Controller.Net
- Item/user/session ids (SessionInfo.Id/UserId, BaseItemDto.Id) are plain
  string on this SDK version, not Guid - Party/PartyMember updated
  accordingly (Party.Id stays a Guid; it's minted by this plugin, not Emby)
- Reading the calling user needed an extra step:
  IAuthorizationContext.GetAuthorizationInfo(Request).UserId is a `long`
  (internal row id); .User.Id is the one matching SessionInfo.UserId,
  but it's a Guid so it's reformatted with .ToString("N")

docs/architecture.md's "Assumptions to verify" now reflects what was
actually confirmed vs. what's still only compile-checked (HTTP status
codes, content-type headers, the JS auth header format, the dashboard
config page) pending a run against a real server.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 14:40:17 +02:00
66af05f259 Scaffold Emby Watch Party plugin
Design: coordinate parties on top of Emby's existing session
remote-control commands (SendPlaystateCommand) rather than building a
new player. Each member keeps their own Emby app/controls; the server
periodically corrects drift and mirrors pause/unpause against one
reference member's reported position.

- src/WatchParty: plugin skeleton (BasePlugin, IServerEntryPoint,
  IService/[Route] API, in-memory party store, drift-correction loop)
- web/control-panel: plain HTML/JS/CSS companion page to create/join
  parties and resync, served by the plugin itself
- docs/architecture.md: full design, sources consulted, and an
  explicit list of SDK details that still need verifying against a
  real build (no .NET SDK / Emby SDK assemblies available in this
  environment to compile against)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 14:02:43 +02:00