Commit graph

32 commits

Author SHA1 Message Date
1e437e4a91 Give static watch parties their own tab and one file per party
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
- new "Parties permanentes" tab (/static, sidebar + mobile sheet),
  highlighted on a party's own page too; the home page no longer lists
  static parties, and its live "Watch parties en cours" list leaves
  their sessions out
- StaticPartyStore now writes data/static-parties/<id>.json - one file
  per party, debounced per party, so a write only touches the party that
  changed and a corrupt file only costs that party (moved aside to
  .corrupt, the rest still load); leftover .tmp files are cleaned up;
  deleting a party removes its file and cancels any pending save
- the first release's single static-parties.json is migrated once on
  load, then kept as .migrated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:16:55 +02:00
5182def5c9 Add static (persistent) watch parties
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
A watch party built from a whole series or video playlist that remembers
the group's progress and each member's, so someone coming back or joining
late sees the items they missed and can catch up (solo, as a catch-up
watch party, or by marking items watched).

- StaticPartyService + StaticPartyStore: first on-disk state (JSON file
  under DATA_DIR, atomic debounced writes, flushed on SIGTERM)
- each session is an ordinary Party linked via party.staticLink; group
  position is saved by the 20s sweep and resumed on the next session
- PartyService.completeCurrentItem centralizes "item done" (Emby Played +
  static progress); new host-only "itemEnded" socket event covers the
  last queued item, which was never marked Played before
- missed = group-completed minus watched in-app minus Emby Played
- /static/:id view, create buttons on series/playlist pages, home section
- Docker volume for /data, spec/static-watch-party.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:54:47 +02:00
6bec09ae20 Merge remote-tracking branch 'origin/master' into fix/track-image-albumid-fallback
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-18 11:36:09 +02:00
5b2b832be7 Fix a track started directly never falling back to its albums artwork
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
imageUrlFors own Audio -> AlbumId fallback was silently never
triggering for a track played directly (not via its album/artist),
because the raw item that decision runs against - CatalogService
getRawItem - never requested AlbumId from Emby. Its default field set
for a single-item GET omits it entirely, so item.AlbumId read
undefined regardless of whether the tracks actual album had one -
falling through to the tracks own id every time, which essentially
never has a Primary image of its own. Every other path that resolves a
tracks image already asks for AlbumId correctly (getAlbumTracks/
getPlaylistItemsRaw), which is why launching a whole album/artist/
playlist was never affected. Fixed the one gap: getRawItem now
requests Fields=AlbumId too.

Flagged directly as probably not the whole images-are-hard-to-find
story - a real Emby library can also legitimately have no Primary
image at all for a given artist/album, which is not a bug this app can
fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 10:26:33 +02:00
3ae1fce806 Fix device takeover leaving both devices on the session with no working controls
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Two gaps around an already-correct takeover mechanism, not the
mechanism itself. First: Player.ts/MusicBar.ts both wired onClose to a
no-op, so the moment a takeover force-disconnected the old device its
video/hls.js instance (or the persistent music bar) just kept running
forever on a dead connection - both devices looked fully active, since
nothing ever told the old one to stop or navigate away. Second: the
obvious fix (call the same leave path Quitter uses) ran into a worse
bug - POST /leave removes a member by userId alone with no staleness
guard, so the old devices own leave call would have ended the NEW
devices membership too. Fixed both: onClose now tears down local
playback and leaves whenever this routes own explicit teardown has not
already run, and the leave route (plus the client, which now sends its
own deviceId) gained the same staleness guard the socket handlers
already had, via a new PartyService.isStaleDevice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 10:23:22 +02:00
62afa88bb7 Queue a whole album or an artist's entire discography in one action
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Asked for directly: views/album's and views/artist's per-track/
per-album buttons had no whole-album or whole-discography equivalent -
queuing an album meant clicking every track one at a time, and an
artist page had no listen-party action at all beyond drilling into a
specific album.

Resolved server-side, not via a client-side loop - the same choice
already made for playlists, and for the same reason: an artist's
entire discography can genuinely be dozens of albums and hundreds of
tracks, and a client loop of individual add-track calls would mean
that many sequential round trips (slow, and non-atomic) instead of
one. New resolveMusicTracksForItem (index.ts) takes a raw Emby item
already known to be Audio/MusicAlbum/MusicArtist and resolves it to a
flat, ordered track list - a single track, every track in an album (in
Emby's own IndexNumber order), or every album's tracks for an artist's
whole discography (fetched in parallel, in Emby's own chronological
album order). POST /api/parties gained a MusicAlbum/MusicArtist branch
right alongside the existing Playlist one, using this plus a new
shared createMusicPartyFromTracks - which also let
createPartyFromPlaylist's own music branch drop its separate per-track
loop in favor of the same two calls.

MusicQueueService.addTracks is the batch sibling of the existing
addTrack (both now share a buildTrackEntry helper) - same
auto-starts-if-the-queue-was-empty behavior, but one queueUpdated
broadcast for the whole batch rather than one per track, so a
hundred-track discography doesn't spam the party with a hundred
intermediate broadcasts for what's really one user action.

Adding to an already-live party's queue needed its own new route, POST
/api/parties/:partyId/queue - given just an itemId, resolves it the
same way and calls addTracks. Kept separate from the existing
POST .../tracks, which takes an already-built track object a caller
already has in hand (views/album's own per-track buttons) - overloading
that route to guess whether it received a full track or a bare id
would have been more surprising than a second, clearly-named one.

The buttons themselves mirror views/playlist's own "Lancer cette
playlist" - same label-reflects-current-state-and-re-checks-on-click
discipline views/album's per-track buttons already had, same
attach-the-bar-and-stay-put behavior when starting fresh. views/album
gained one page-level button (reusing .playlistActions from
Playlist.css, same "one CSS bundle regardless of which view's file a
rule started in" precedent Album.css already set for .seriesInfo).
views/artist gained its first listen-party action ever, sitting in
.pageHeader itself (its own justify-content: space-between already
puts a second child on the right of the title with no new CSS needed).

Verified: full typecheck/test/build on both projects (147/147 server
tests - new coverage for addTracks: batch order preserved, auto-start
only fires for the batch's own first track when the queue was empty,
exactly one broadcast regardless of batch size, harmless no-op for an
empty batch). resolveMusicTracksForItem/createMusicPartyFromTracks
themselves are index.ts route glue, not independently unit-tested -
consistent with this project's existing boundary that route-level
orchestration isn't, only the services it calls into are. The two new
buttons' layout was checked visually against the real compiled CSS (a
static render, real theme tokens), not eyeballed. Not verified: an
actual large discography queuing correctly end to end against a real
server - no Emby login reachable from this sandbox.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 07:46:38 +02:00
7bf8197bfe Merge remote-tracking branch 'origin/master' into feature/sticky-search-and-music-seek
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-16 07:20:57 +02:00
bc878c8d1c Merge remote-tracking branch 'origin/master' into feature/intro-credits-skip
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-16 07:18:43 +02:00
ceea7432ef Report watch-party streams to Emby's own Sessions API (admin dashboard)
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 5s
Publier l'image Docker / publish (pull_request) Has been skipped
Asked for directly: Emby's admin "now playing" dashboard should see
this app's streams the same way it sees a real web/mobile/TV client's.
This is exactly the follow-up "Sync watch-party progress back to Emby"
already anticipated and deliberately deferred at the time ("wiring
clean start/stop into every transition... is real complexity for a
secondary payoff") - that section also explains why this app abandoned
Emby's session-shaped PlayingItems endpoints entirely in favor of
direct UserData writes. Given that history (two separate rounds of a
wrong-but-schema-plausible request shape for that other, now-abandoned
endpoint family), this one was built checking the real API first
rather than assuming.

Confirmed against the real server's own /openapi.json that the actual
session-lifecycle family is POST /Sessions/Playing (start),
/Sessions/Playing/Progress, and /Sessions/Playing/Stopped - distinct
from PlayingItems. None of the three schemas declare a required list
at all, which - given the neighboring history - was read as "don't
trust that": ItemId/MediaSourceId/PlaySessionId/PositionTicks are sent
on every call anyway, matching what a real client obviously would,
rather than risking a repeat of "technically matches the schema, still
400s".

One real Emby session per PARTY MEMBER, not one per party - a faithful
copy of how a real client behaves, and the actual point of "let admin
see who's watching what" would be lost by collapsing everyone onto one
shared entry. New server/src/services/EmbySessionService.ts mirrors
PlaystateService's own best-effort shape (every failure caught and
logged, never thrown) but stays its own class - PlaystateService's own
opening comment is specifically about why it doesn't use a
session-shaped endpoint, so folding this into it would muddy that
exact distinction.

Getting a MediaSourceId/PlaySessionId per member needed a new
CatalogService.negotiatePlaybackSession - a fresh PlaybackInfo
negotiation under that specific member's own account, since Emby scopes
PlaybackInfo per user and PartyStreamService's existing shared
negotiation is never guaranteed valid for reporting under everyone
else's account too. Returns null on any failure rather than throwing -
this only ever feeds an admin-dashboard nicety, never something that
should disrupt real playback.

Driven entirely from index.ts's existing periodic sweep rather than new
hooks scattered across every queue-advance/track-skip call site -
that sweep already visits every connected member on every ~20s tick,
already positioned to notice a member's current item no longer matches
their cached session and react (stop the old one if any, negotiate
fresh ids, start the new one; same item as last tick just sends
Progress with the cached ids). PartyMember gained the nullable
embySession field this tracks. Explicit stop calls also happen at
PartyService.leave() (alongside the existing CastTokenService
.revokeForMember) and removeIdleParties (a backstop for a disconnect
that never triggered a clean leave).

Verified: full typecheck/test/build (134/134 server tests - new
coverage for negotiatePlaybackSession's null-on-any-failure contract,
every EmbySessionService method's exact request shape, that a non-ok
response/thrown error is swallowed not propagated, and that leave()
calls stop with a member's real cached session info but only when they
actually had one open). Not verified: an actual live write against the
real server - deliberately not attempted, since that would mean using
a real account's Emby token for a genuine state-changing call, beyond
the read-only diagnostic use this session's shared API key was ever
meant for. So while the request shape is checked against the real
schema and the per-member logic is unit-tested in full, whether Emby's
dashboard actually displays these sessions the way a native client's
would - correct device/client labeling in particular, since this app
never sends the X-Emby-Authorization Device/Client/Version header a
real client would, only X-Emby-Token - is genuinely unconfirmed. Worth
watching the real dashboard the first time a party runs after this ships.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:45:14 +02:00
fafee359b9 Sticky catalog search bar, and let anyone seek within a music track
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Two small, unrelated requests handled together.

The search bar (tiles.ts's shared .searchBar, used by views/library/
artist/album) scrolled away with the page - asked to stay reachable
while scrolling a long grid. position: sticky; top: 0 on the input
itself; base.css's own generic input rule already gives it an opaque
background, which is what keeps tiles scrolling underneath from
showing through once it's stuck - nothing else needed changing.

Seeking within the current music track was explicit, deliberate scope
this session's own "participative playlist" work had cut - the
progress bar was marked read-only specifically because MusicQueueService
had no seek concept, just play/pause/advance. The permission model was
already decided by that class's own documented reasoning for why
*anyone* can pause/resume (reversible, doesn't change what's playing) -
a seek is exactly that same shape, continuous instead of binary, so it
gets the same treatment, not voteSkip's majority gate.

MusicQueueService.seek mirrors setPaused's own elapsed-time math
(clamped into [0, durationSeconds]), with one real difference: setPaused's
resume branch gets away with calling scheduleAdvance directly only
because pausing already cleared the timer first - scheduleAdvance
itself never clears a prior one. Seeking while still playing has no
such guarantee, so skipping that clear first would leave the OLD timer
(counting down against the old position) running alongside the new
one, eventually firing playNext at the wrong moment. New musicSeek
socket event, no host check (same as musicPlaystate). Client-side,
MusicBar's progress track gained the same pointerdown/move/up
drag-to-seek handlers the video player's seek bar already has - no host
branch to port this time, since a listen party has none - plus a
local, optimistic activeDriver.currentTime write before broadcasting,
for the same instant-feedback reason the video player's own
seekFromPointerEvent does that.

Verified: full typecheck/test/build on both projects (128/128 server
tests - new coverage for seek's clamping, its paused-vs-playing
branches, and a real-timer test proving the old advance timer is
cleared rather than left running alongside a re-armed one - the exact
bug the naive "just call scheduleAdvance again" version would have
shipped). No server needed for the sticky search bar - checked by
reading .mainContent's own layout directly (no nested scroll container
between it and the document, confirming position: sticky resolves
against the page scroll as intended).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:27:23 +02:00
57bb297bff Add skip intro / next episode at credits, via Emby's own detection
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Asked for directly. Checked the real server before building anything:
no plugin provides this (Cinema Intros is a different feature - pre-show
trailers, not per-episode skip points), and Emby's own Chapters field
turned out to be auto-generated placeholders every 5 minutes, not real
markers.

What the server does have natively, confirmed from its own
/openapi.json rather than assumed: a per-library EnableMarkerDetection
option backed by a built-in "Detect Episode Intros" scheduled task -
audio-fingerprint detection, the same idea as Jellyfin's separate Intro
Skipper plugin, already part of Emby itself. Enabling it (done directly
on the server for the two TV libraries) adds real entries to the same
Chapters array, distinguished by MarkerType: IntroStart/IntroEnd/
CreditsStart.

This made the feature almost entirely "read what Emby already
computed" - no fingerprinting, no new persistence, no background jobs
of this app's own. CatalogService.getPlaybackMarkers fetches
Fields=Chapters and picks out just those three marker types (nulls
when detection hasn't reached this item or isn't enabled for its
library - never an error), behind a new
GET /api/catalog/item/:id/markers route mirroring getPlaybackOptions.

The player gets one new floating button, #skipIntroBtn - an explicit
label, not an icon, matching this app's own convention for every
request-shaped control. It's a sibling of #bottomBar, not inside it,
so it stays reachable through the bar's own auto-hide/.minimal collapse.
Clicking it seeks to introEndTicks through the exact same
activeDriver/onDriverStateChanged path any other seek uses, so it's
automatically host-only and automatically cast-aware with no new
special-casing for either. End credits got no button of their own -
#nextBtn already does exactly "go to the next queued item" and was
already shown whenever hasNextQueued is true; the only gap closed here
is visibility, by un-minimizing the bar once (a one-shot flag, not a
fight against someone deliberately re-minimizing it) the moment
playback crosses creditsStartTicks.

Verified: full typecheck/test/build on both projects (125/125 server
tests, including new coverage for getPlaybackMarkers - real MarkerType
values mixed with auto-generated Chapter ones, and both "no Chapters"
and "no markers among the Chapters" resolving to nulls rather than
throwing). The button's on-screen position was checked directly
(getBoundingClientRect against the real compiled CSS) at two viewport
sizes - clear of #bottomBar with a 10px gap, not overlapping. Not
verified: the detection task had only just been enabled on the real
server and hadn't finished analyzing any episode yet, so the actual
end-to-end "a detected marker makes the button appear at the right
second" behavior needs a real check once it's had time to run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:16:38 +02:00
62b0abd572 Fix casting past the first segment, and the toolbar's real auto-hide bug
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 12s
Two bugs reported together, both surfaced by actually trying this for
real.

Casting: "the .m3u8 needs to be passed instead of a raw [url]". The
master playlist itself was fine - the initial master.m3u8?castToken=...
request already carries the token. What broke immediately after: every
URI line inside that playlist gets rewritten (EmbyClient.rewritePlaylist)
to route back through this app's own party-stream/:partyId/raw?u=...
proxy, and that rewrite never carried the castToken forward. A browser
never noticed (it's still sending its cookie regardless of the URL), but
a Chromecast has no cookie to fall back on, so every segment/variant-
playlist fetch after the first one 401'd, breaking playback moments
after the receiver appeared to connect. Fixed by threading the same
castToken into every rewritten line (rewritePlaylist(text, routeKey,
castToken?), sourced from req.query.castToken right inside
proxyResponse). The self-hosted burn-in path had the identical gap for
a different reason - ffmpeg's own bare relative segment filenames drop
any query string when resolved against a URL that had one, per ordinary
URL-resolution rules - fixed the same way with a new pure
appendCastTokenToSegments(text, castToken), extracted specifically so
it's unit-testable without a real ffmpeg session.

Player toolbar: "still doesn't work, mobile and PC" - the auto-hide fix
from two commits ago hadn't actually landed for anyone. Real bug, not a
browser quirk this time: becomeHost's 3-second heartbeat got wired to
the same onDriverStateChanged function real discrete play/pause/seeked
events use, which also re-arms the auto-hide countdown - correct for a
genuine event, wrong for a heartbeat that fires unconditionally every
3s regardless of any real change. With the hide delay also at 3000ms,
every heartbeat tick cancelled and restarted the countdown on the exact
cadence it needed to run uninterrupted to ever complete - so for a
host, on any platform, the bar could never actually finish hiding.
Fixed by giving the heartbeat its own function, reportHeartbeat - same
state sync, without the scheduleControlsHide call.

Verified: full typecheck/test/build (122/122 server tests, including
new coverage for rewritePlaylist's token propagation - had none at all
before this - and appendCastTokenToSegments). Confirmed the heartbeat
fix directly: 3 simulated heartbeat ticks call the hide-timer's reset
zero times, one simulated real event still calls it once. Not verified:
a real Chromecast fetching real rewritten segment URLs - no device
reachable from this sandbox.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 20:18:29 +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
e6d71f2e37 Log the playstate sweep's own requests, not just its failures
All checks were successful
Publier l'image Docker / test (push) Successful in 22s
Publier l'image Docker / publish (push) Successful in 14s
Follow-up to the last fix (wrong Progress param shape): now that the
request shape is corrected, the obvious next question is "is it
actually being sent right, and is Emby accepting it" - answered from
the logs alone, without cross-referencing anything else.

PlaystateService.reportProgress now logs the exact outgoing request
right before sending it (method, path, query string - everything
actually sent) and its outcome right after (the real HTTP status and
whether it was ok, or that the call threw), all under
[watchparty:playstate]. EmbyClient's own generic [watchparty:emby]
failure log still fires too, independently, with the fuller error
body - this is a second, feature-scoped log so `docker logs | grep
watchparty:playstate` alone shows the whole story for this specific
sweep.

Deliberately safe to log the full path: the query string here is only
ever MediaSourceId/PositionTicks/IsPaused, never a token - the real
embyToken travels as a header, not the URL - unlike Logger.stream's
own request logging, which stays partial specifically because Emby's
own TranscodingUrl can carry an api_key.

Tests: request logging (path/params present, token absent), and
outcome logging for a success, a non-ok status, and a thrown error
each producing a distinct line. Full typecheck/test/build green
(96/96 tests). docs/architecture.md updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 22:42:49 +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
838244ec41 Fix server crash: stale connection's heartbeat reading a deleted member
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 18s
Reported directly from the container's crash output: TypeError: Cannot
read properties of undefined (reading 'isPaused') inside SocketGateway's
"playstate" handler, taking down the entire Node process - every active
party, not just one connection.

Root cause: party.members is keyed purely by userId, with no per-device
identity yet (the gap the "same account, different device" feature also
needs - see docs/architecture.md). Two connections for the same userId
share one map slot; PartyService.join/create both unconditionally
overwrite it. When the connection currently occupying that slot is the
party's last member and leaves, leave() deletes the entry and - by
design, since there's no successor - never reassigns the now-stale
party.hostUserId. The OTHER connection for that same userId, still fully
connected with its own closure referencing the same now-destroyed Party
object, sends its regular 3s heartbeat next: the host check passes
(stale userId equals stale hostUserId on both sides), then
party.members.get(userId)! finds nothing at all. That's the crash.

Fixed at two levels:
- server/src/realtime/SocketGateway.ts: the "playstate" handler's
  unchecked `!` is now a real `if (!member) return`, matching the guard
  every other handler already used for the same lookup.
- Every socket.on(...) in attach() now goes through a new safe()
  wrapper (log-and-swallow on an unexpected exception) - Socket.IO has
  no equivalent of Express's error-handling middleware (asyncRoute()
  already covers every REST route for exactly this), so a synchronous
  throw from any listener, this one or a future one, would otherwise
  keep crashing the whole process outright.
- server/test/socketGateway.test.ts: a regression test reproducing the
  exact sequence. Verified it's meaningful, not vacuous, by temporarily
  reverting just the specific guard and confirming that same test
  correctly fails before restoring the fix.

Deliberately not changing how join/leave share that one map slot in this
pass - that's the real design work the fuller device-detection feature
still needs. This stops it from being able to crash the server in the
meantime, regardless of how that gets resolved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 16:53:31 +02:00
137977893a Render subtitles client-side (<track>) instead of asking Emby to burn them in
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 14s
Six rounds of real-server debugging (see docs/architecture.md's player-
track-selection section) found three different, unrelated reasons a
server-side burn-in could fail - missing fonts, a bitmap format libass
fundamentally can't burn, ffmpeg failing outright on an otherwise
unremarkable file - none of which this app's own request ever caused or
could have fixed, all downstream of the one thing every attempt had in
common: asking Emby for a real-time video re-encode. Not needing that
re-encode at all closes the whole class of failure instead of chasing
each one.

- server/src/services/CatalogService.ts: new proxySubtitle asks Emby to
  convert the track to WebVTT (GET /Videos/{id}/{mediaSourceId}/
  Subtitles/{index}/Stream.vtt - a real Emby endpoint their own web
  client already uses for this) - entirely separate from the video
  transcode. applyPlaybackOverrides lost its SubtitleStreamIndex/
  SubtitleMethod=Encode branch - the video request no longer needs to
  know a subtitle was picked at all.
- server/src/types.ts, client/src/types.ts: PlaybackTrackOption's flag
  renamed supportsBurnIn -> isTextBased - same underlying fact (image vs
  text codec), but the name shouldn't keep describing a mechanism this
  app no longer uses.
- server/src/index.ts: new GET /api/catalog/subtitle/:itemId/:subtitleIndex
  route, grouped with the other cookie-authed media-element routes (a
  <track> can't send a bearer header either).
- client/src/api/embyApi.ts: new subtitleUrl(); streamUrl no longer
  carries SubtitleStreamIndex at all.
- client/src/views/player/Player.ts: the subtitle <select> no longer
  reloads the video (reloadWithCurrentOptions, still used for audio/
  quality, never touches subtitles now). New applySubtitleTrack()
  removes/re-adds a fresh <track> and sets video.textTracks[0].mode =
  'showing' synchronously right after appending - deliberately not
  waiting on the <track>'s own "load" event, confirmed against a real
  browser that it doesn't fire reliably at all (a known cross-browser
  quirk), which would have left a subtitle silently never shown - the
  exact symptom this whole saga started from. Re-applied on every video
  (re)load so an audio/quality change (which does still reload the
  video) doesn't drop whatever subtitle was showing.
- server/test/catalogService.test.ts: updated/new cases for
  proxySubtitle and the renamed isTextBased flag; removed the test for
  the now-deleted subtitle-forcing behavior.

Verified against the real compiled bundle: picking a text track adds one
<track> with the right URL and flips it to showing; "Aucun" removes it;
an image-coded track is listed but disabled; changing audio reloads the
video but the subtitle survives it, same element, still showing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 16:31:25 +02:00
1e4a82df16 Log Emby's real status + response body on any proxied call failure
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 24s
Every attempt to see Emby's actual error message by hand failed the same
way: hls.js's own error reporting only ever exposes the generic HTTP
reason phrase, never the real response body, and Emby's own live log
view didn't reliably surface the exception's headline message either -
across several different items and failure modes this session. Emby's
own access log confirmed a real, several-KB text body really was being
sent every time (Content-Type: text/plain, Content-Length: 5190) - it
just never survived being reported back through either of those paths.

Fixed at the root: EmbyClient.fetch - the one function every Emby call in
this app goes through (fetchJson's JSON API calls and proxyResponse's
streaming/image proxy alike) - now logs Emby's status and full response
body itself straight to docker logs whenever a call fails.

- server/src/emby/EmbyClient.ts: new private logErrorResponse, called
  from fetch() only when !res.ok. Reads the body via .clone() so the real
  caller still gets an untouched, fully readable response (a body can
  only be consumed once, and this had to not break either of the two very
  different ways this app's callers read one - fetchJson's .json(),
  proxyResponse's .text()/pipe). pathAndQuery is logged with api_key
  redacted - this can be called with Emby's own TranscodingUrl verbatim,
  which can carry one (same concern as CatalogService's logStreamRequest).
- server/src/Logger.ts: new emby() method, same shape as party()/stream().
- server/test/embyClient.test.ts (new): body/status logged and readable
  by the real caller afterward, api_key redaction, and silence on a
  successful response (only failures are worth logging, same discipline
  as this app's existing logging).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 15:03:17 +02:00
2f670ec9a4 Flag image-based subtitle tracks (PGS/VobSub/DVB) as not burn-in-able
All checks were successful
Publier l'image Docker / test (push) Successful in 22s
Publier l'image Docker / publish (push) Successful in 14s
Two different items both failed the same way when picking a subtitle -
without knowing whether the second one was also a PGS (image/bitmap)
track like the first confirmed one, that result was ambiguous: still the
same known issue, or something new? Rather than keep digging through
server logs per item, this app can just know the answer itself - Emby's
PlaybackInfo response already includes each subtitle stream's Codec.

- server/src/services/CatalogService.ts: getPlaybackOptions reads it and
  reports supportsBurnIn: false for image-based subtitle codecs
  (hdmv_pgs_subtitle/dvd_subtitle/dvb_subtitle - IMAGE_SUBTITLE_CODECS).
  This app forces SubtitleMethod=Encode whenever a subtitle is picked
  (applyPlaybackOverrides), asking ffmpeg's `subtitles` filter (libass)
  to burn it in - libass renders text via a font, it has no path for
  compositing a pre-rendered bitmap at all, confirmed against a real
  server (see docs/architecture.md's Round 4/5).
- server/src/types.ts, client/src/types.ts: PlaybackTrackOption gains
  supportsBurnIn (subtitle tracks only, undefined for audio).
- client/src/views/player/Player.ts: the subtitle <select> now disables
  these options with a "(image, non disponible)" label instead of
  letting anyone pick a track that's guaranteed to break playback -
  listed so it's clear why, not silently missing.
- server/test/catalogService.test.ts: new case covering subrip/ass
  (supportsBurnIn: true) vs hdmv_pgs_subtitle (false) from the same
  MediaStreams response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 14:45:38 +02:00
a5aca4a823 Fix home page's party "elapsed" label ignoring pauses
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 15s
Reported: the time shown for an ongoing party is the movie's elapsed
time. True, but only by coincidence - the label was actually just
Date.now() - party.createdUtc, which climbs at wall-clock speed
regardless of whether the movie is actually playing. Pause it for five
minutes and the home page keeps counting through the pause the player
itself has frozen.

- server/src/types.ts, client/src/types.ts: LobbyPartyDto gains
  positionTicks/isPaused/positionUpdatedUtc - the host's own tracked
  playback state (same values SocketGateway already maintains for their
  party's own room), null for music parties, where there's no single
  "elapsed" across a rotating queue of tracks.
- server/src/realtime/SocketGateway.ts: lobbySnapshot() fills these from
  the host's member record for video parties. Two new broadcastLobby()
  trigger points to keep it reasonably fresh: an actual pause/play
  transition (not every 3s heartbeat resend - same guard already used
  for that transition's log line) and advancing the queue (advanceQueue
  already reset every member's position for the new item; nothing had
  ever pushed that to the lobby before, so a card's title/poster/position
  could sit stale until an unrelated join/leave refreshed it).
- client/src/views/home/Home.ts: updateElapsedLabels() now computes a
  live, pause-aware position for a video party (frozen while paused,
  ticking from position+timestamp while playing) instead of always
  falling back to createdUtc - which is still exactly what a music party
  (or a party missing these fields) uses.
- server/test/socketGateway.test.ts: two new cases - the lobby snapshot's
  video-vs-music shape, and that a transition/next() refreshes it while
  an unchanged heartbeat resend doesn't.

Verified against the real bundled client with a faked "parties" push:
a paused party's label held still across a real wait; a playing one, and
a music party on the unchanged createdUtc path, both kept ticking up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:53:10 +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