Commit graph

6 commits

Author SHA1 Message Date
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
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
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
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
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