18 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 5182def5c9 |
Add static (persistent) watch parties
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> |
|||
| 6b6a57a5f7 |
Fix the alphabet index bucketing by display name instead of Emby SortName
Emby orders (and this app already asked it to) by SortName, which strips leading articles - an item like The Bunker sorts among the Bs there, but the index was bucketing by the raw display name, throwing a spurious T heading into the middle of that run. getLibraryItems/ getArtists now also fetch SortName, ItemDto carries it through, and the index buckets by that instead - the same key Emby actually used to order the array, not a guess that usually matches it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| 7bf8197bfe |
Merge remote-tracking branch 'origin/master' into feature/sticky-search-and-music-seek
# Conflicts: # README.md # docs/architecture.md |
|||
| bc878c8d1c |
Merge remote-tracking branch 'origin/master' into feature/intro-credits-skip
# Conflicts: # README.md # docs/architecture.md |
|||
| ceea7432ef |
Report watch-party streams to Emby's own Sessions API (admin dashboard)
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>
|
|||
| fafee359b9 |
Sticky catalog search bar, and let anyone seek within a music track
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> |
|||
| 57bb297bff |
Add skip intro / next episode at credits, via Emby's own detection
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> |
|||
| 5a1bc8d932 |
Fix the listen-party queue's layout and its missing album art
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>
|
|||
| 4efcd67fb3 |
Sync text subtitle choice too, and fix an empty audio option
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> |
|||
| f78b2f1408 |
One shared Emby stream per party, not one per viewer
Asked for directly: exactly one stream should ever be open between this app and Emby per party, no matter how many members it has. Before this, every viewer's own <video>/<audio> independently negotiated its own PlaybackInfo and got its own PlaySessionId - a party of 5 meant Emby transcoding the same title 5 separate times. A bigger, higher-regression-risk change than most of this session's other work: it replaces the core playback path every existing party already relies on (CatalogService.proxyStream and its route are gone entirely) rather than adding something alongside it - there's no other consumer of the per-viewer path, and the ask was explicit that there should only ever be one mechanism. - server/src/services/CatalogService.ts: proxyStream's negotiate-then- build-a-target logic extracted into negotiateStreamTarget(session, itemId, isAudio, audioStreamIndex, maxStreamingBitrate) - identical logic, returns the target instead of proxying it directly so it can be cached. proxyStream/proxyRawStreamAsset removed. - server/src/services/PartyStreamService.ts (new): one negotiated target per party.id, reused by every member; renegotiates when the desired item/audio/quality changes. Recovers from a dead upstream session with no new plumbing - EmbyClient.proxyResponse already sets res.statusCode before writing the body, so a failed proxied response is detected right after it resolves and the cache entry is dropped, so the next request (a manual retry, or the next segment fetch) renegotiates fresh instead of repeating the same dead target forever. - server/src/emby/EmbyClient.ts: rewritePlaylist/proxyResponse's trailing itemId param genericized to routeKey - it's only ever read back to build the raw-asset URL, so it's party.id for the new stream routes and an inert real itemId for proxyImage/proxySubtitle (which never reach that branch anyway). - server/src/index.ts: GET /api/catalog/party-stream/:partyId/ master.m3u8 and .../raw replace /api/catalog/stream/:itemId/... entirely, membership-checked like the burn-in routes; a new idle-reap interval for PartyStreamService, same shape as TranscodeService's own. - Forced consequence of "one stream", not a free design choice: audio track/quality become a host-only, party-wide choice for video - Party.streamAudioIndex/streamQuality, setStreamOptions/ streamOptionsChanged, byte-for-byte the same shape setBurnInSubtitle/burnInChanged already established for burn-in subtitles. server/src/realtime/SocketGateway.ts gains the host-only, video-only handler; server/src/services/PartyService.ts resets streamAudioIndex (not streamQuality - a bandwidth preference isn't item-specific) on advanceQueue, same reasoning as burnIn* already gets; MusicQueueService.playNext ends the party's cached session on a track change. - client/src/api/embyApi.ts: streamUrl/audioStreamUrl replaced by one partyStreamUrl(partyId) - no query params needed any more, the party itself is the source of truth for what it's streaming. - client/src/api/party.ts: setStreamOptions/onStreamOptionsChanged. - client/src/views/player/Player.ts: audio/quality <select>s disabled for a follower (applyStreamOptionsGating, mirroring renderSubtitleOptions' own gating), restore the party's current choice on load/broadcast, and a host's change calls setStreamOptions instead of reloading locally - every member (host included) only ever reacts to the resulting broadcast, same discipline the rest of this player already uses. Dead reloadWithCurrentOptions removed. - client/src/musicBar/MusicBar.ts: loadTrack points at partyStreamUrl(activePartyId) - no picker to gate, MusicBar never had one. - server/test/catalogService.test.ts: proxyStream's old cases moved to exercise negotiateStreamTarget instead, plus new quality-override and no-source-fails cases. server/test/partyStreamService.test.ts (new): cache hit/miss/param-change/item-change, the failure-invalidation path, endSession, idle reap. server/test/socketGateway.test.ts: setStreamOptions host-only gating + broadcast, mirroring the existing setBurnInSubtitle tests. - docs/architecture.md, README.md: design, the forced audio/quality trade-off spelled out, and what's still unverified (a different kind than this session's other Emby-API guesses - the calls themselves were already live code, what's untested is real multi-viewer load). Verified: full typecheck+test+build on both projects (85/85 server tests, 12 new). A real-browser check (mocked-fetch + real bundle, this session's standing harness) confirmed a follower's audio/quality selects render disabled and a host's render enabled, from the same party data with only youAreHost flipped. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
|||
| 11f003d269 |
Sync watch-party progress back to Emby (PlaystateService)
This app has been read/proxy-only toward Emby so far. Queued right
after the playlist feature, asked for directly: while someone's in a
party, push their progress to Emby's own PlaystateService, so their own
Emby library (resume position, last-played state) stays in sync with
the watch/listen party, under their own account - not just the host's,
and not just this app's own memory of it.
Scoped to periodic progress reporting only for v1 - no explicit
start/stop calls (Emby's own "now playing" list won't show this app's
streams, and "mark fully watched" isn't guaranteed the way a clean Stop
near 100% would trigger it). Steady Progress calls alone already cover
the actual ask; wiring clean start/stop into every transition (item
change, leave, party destroy, a device takeover) is real complexity for
a secondary payoff - left as a documented follow-up.
- server/src/types.ts: PartyMember gains embyToken (null until known,
same shape as this session's own deviceId addition) - never sent to
the client, toPublicState's member mapping doesn't grow it.
- server/src/emby/EmbyClient.ts: fetch() now optionally takes
method/body (JSON-encoded once, here) - every existing GET-only call
site unaffected, both are optional.
- server/src/services/PlaystateService.ts (new): reportProgress(token,
userId, itemId, positionTicks, isPaused) - POSTs to
/Users/{userId}/PlayingItems/{itemId}/Progress. Catches and logs its
own failures (new Logger.playstate) rather than throwing - this runs
on a timer with nothing to catch a rejection, and one bad token or a
transient Emby hiccup must never touch real playback.
- server/src/services/PartyService.ts: new setMemberEmbyToken (records
a member's own token) and getCurrentPlaybackState (video: the host's
own tracked position - there's no per-follower tracking today, a
reasonable approximation given the player's own drift correction;
music: the one shared, server-authoritative position every member
already sees the same way). The one real piece of logic here is pure
and unit-tested on its own, same shape as this session's
absoluteToRelativeIndex/splitPlaylistByMediaType.
- server/src/realtime/SocketGateway.ts: attach()'s getSession callback
widened to expose embyToken - it was already being passed the full
EmbySession (AuthService.getSession), just typed down to {userId,
userName} before anything downstream could read the rest. The "join"
handler now also records member.embyToken alongside deviceId.
- server/src/index.ts: setMemberEmbyToken called after each of the 4
REST create/join call sites, so a member has a valid token from the
moment they're a member, not only once their socket first connects.
New ~20s setInterval (same shape as the existing removeIdleParties/
TranscodeService.reapIdleSessions timers): for every active party,
getCurrentPlaybackState once, then reportProgress for every connected
member with a known token.
- server/test/embyClient.test.ts, partyService.test.ts,
playstateService.test.ts (new): the new body-encoding path (and that
a plain GET stays untouched), getCurrentPlaybackState's video/music/
empty branches, setMemberEmbyToken, and that reportProgress's request
shape is right and a thrown failure never propagates.
- docs/architecture.md, README.md: design + the deliberate v1 scope and
the follower-position approximation spelled out.
No client changes - this is a pure server-side background process with
nothing for the browser to render or react to.
Verified: full typecheck+test+build on both projects (71/71 server
tests, 9 new). No Emby server is reachable from this dev environment,
so the actual endpoint/body shape is unverified end-to-end - flagged
explicitly in the docs, the third such Emby-API assumption this session
(after the ffmpeg direct-stream URL and the Playlists endpoint, both
fine once actually tried).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|||
| 26da8fc5dc |
Self-hosted ffmpeg pipeline for PGS (image) subtitle burn-in
Asked for directly, right after the client-side <track> redesign closed
every *text* subtitle case: PGS/VobSub/DVB subtitles (Blu-ray/DVD rips)
are bitmaps, not text - there's nothing for Emby's Stream.vtt conversion
to extract, so a native <track> fundamentally can't render one. Rather
than keep asking Emby's own transcode to burn these in (the exact thing
that kept failing in earlier sessions - missing fontconfig, then an
unrelated "Error starting ffmpeg"), this runs ffmpeg ourselves: a PGS
stream decodes like a second video track, and ffmpeg's `overlay` filter
composites it directly - no text rendering, no libass, no fontconfig
anywhere in this path.
One shared stream per PARTY, not per viewer - explicitly asked for after
the initial plan: every member plugs into the exact same server-emitted
stream, mirroring "host drives, everyone follows" rather than a
per-viewer model. Picking a burn-in subtitle is therefore a host-only,
party-wide choice, like play/pause/next already are.
- server/src/services/TranscodeService.ts (new): one ffmpeg session per
partyId. getOrStartSession negotiates PlaybackInfo (via the new
CatalogService.getMediaSource), converts Emby's absolute
MediaStreams[].Index into ffmpeg's type-relative 0:s:N/0🅰️N
(absoluteToRelativeIndex - pure, unit-tested), and spawns ffmpeg
against Emby's own direct/static file endpoint (TODO(verify) - no
Emby server reachable from this dev environment, see the function's
own comment). Output is real progressive HLS to a temp dir, served by
two new routes that poll briefly for the next file. endSession/
reapIdleSessions handle teardown; PartyService's destroy path and
advanceQueue both end a party's session immediately, the idle sweep
is the backstop.
- server/src/services/PartyService.ts: Party/PartyPublicState gain
burnInSubtitleIndex/burnInAudioIndex (null together = no burn-in);
advanceQueue clears them (a new item's indices mean nothing on the
old one).
- server/src/realtime/SocketGateway.ts: new host-only "setBurnInSubtitle"
handler broadcasting "burnInChanged" - same shape as "next"/
"itemChanged".
- server/src/services/CatalogService.ts: new getMediaSource() (id/
container/streams together - what TranscodeService needs); extended
the local PlaybackInfoResponse type with Container.
- server/src/Logger.ts: new Logger.transcode().
- server/src/index.ts: GET /api/catalog/burn/:partyId/master.m3u8 and
.../:segment (membership-checked, same pattern as the party REST
routes); a dedicated idle-reap interval for TranscodeService.
- server/Dockerfile: `apk add ffmpeg` in the runtime stage - no font
packages needed, unlike Emby's own text-subtitle burn-in.
- client/src/api/party.ts, client/src/api/embyApi.ts: setBurnInSubtitle/
onBurnInChanged on PartyConnection, burnInStreamUrl().
- client/src/views/player/Player.{html,css,ts}: the subtitle picker's
image-track options go from unconditionally disabled to host-only
selectable, labeled to say picking one restarts the stream for
everyone. Every member (host included) only ever reacts to the
resulting "burnInChanged" broadcast - never applies a pick
optimistically, same discipline the rest of the player already uses.
Audio/quality are disabled while a burn-in is active (one fixed
encode); a follower's subtitle select is disabled too (nothing left
for them to meaningfully pick).
- server/test/transcodeService.test.ts (new): absoluteToRelativeIndex,
session start/reuse/replace, endSession, reapIdleSessions -
child_process.spawn mocked via a small explicit test seam
(setSpawnForTesting), never invoking a real ffmpeg binary.
- server/test/socketGateway.test.ts, partyService.test.ts,
catalogService.test.ts: host-only gating + broadcast for
setBurnInSubtitle, advanceQueue's reset, toPublicState exposing the
new fields, getMediaSource.
- docs/architecture.md, README.md: design + an explicit "assumptions to
verify" note (the direct-source URL shape, first and foremost).
Verified: full typecheck+test+build on both projects (56/56 server
tests). No Emby server or ffmpeg binary is reachable from this dev
environment, so the actual command against a real PGS file is
unverified - flagged explicitly in the docs, same "TODO(verify)" spirit
already used elsewhere in this codebase for an Emby response shape that
can't be checked locally.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|||
| daab755ce9 |
Device detection: confirm before disconnecting another device
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>
|
|||
| c14d0bc08b |
Replace avatar-click with a request toolbar; add "request resume"
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>
|
|||
| 137977893a |
Render subtitles client-side (<track>) instead of asking Emby to burn them in
Six rounds of real-server debugging (see docs/architecture.md's player-
track-selection section) found three different, unrelated reasons a
server-side burn-in could fail - missing fonts, a bitmap format libass
fundamentally can't burn, ffmpeg failing outright on an otherwise
unremarkable file - none of which this app's own request ever caused or
could have fixed, all downstream of the one thing every attempt had in
common: asking Emby for a real-time video re-encode. Not needing that
re-encode at all closes the whole class of failure instead of chasing
each one.
- server/src/services/CatalogService.ts: new proxySubtitle asks Emby to
convert the track to WebVTT (GET /Videos/{id}/{mediaSourceId}/
Subtitles/{index}/Stream.vtt - a real Emby endpoint their own web
client already uses for this) - entirely separate from the video
transcode. applyPlaybackOverrides lost its SubtitleStreamIndex/
SubtitleMethod=Encode branch - the video request no longer needs to
know a subtitle was picked at all.
- server/src/types.ts, client/src/types.ts: PlaybackTrackOption's flag
renamed supportsBurnIn -> isTextBased - same underlying fact (image vs
text codec), but the name shouldn't keep describing a mechanism this
app no longer uses.
- server/src/index.ts: new GET /api/catalog/subtitle/:itemId/:subtitleIndex
route, grouped with the other cookie-authed media-element routes (a
<track> can't send a bearer header either).
- client/src/api/embyApi.ts: new subtitleUrl(); streamUrl no longer
carries SubtitleStreamIndex at all.
- client/src/views/player/Player.ts: the subtitle <select> no longer
reloads the video (reloadWithCurrentOptions, still used for audio/
quality, never touches subtitles now). New applySubtitleTrack()
removes/re-adds a fresh <track> and sets video.textTracks[0].mode =
'showing' synchronously right after appending - deliberately not
waiting on the <track>'s own "load" event, confirmed against a real
browser that it doesn't fire reliably at all (a known cross-browser
quirk), which would have left a subtitle silently never shown - the
exact symptom this whole saga started from. Re-applied on every video
(re)load so an audio/quality change (which does still reload the
video) doesn't drop whatever subtitle was showing.
- server/test/catalogService.test.ts: updated/new cases for
proxySubtitle and the renamed isTextBased flag; removed the test for
the now-deleted subtitle-forcing behavior.
Verified against the real compiled bundle: picking a text track adds one
<track> with the right URL and flips it to showing; "Aucun" removes it;
an image-coded track is listed but disabled; changing audio reloads the
video but the subtitle survives it, same element, still showing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|||
| 2f670ec9a4 |
Flag image-based subtitle tracks (PGS/VobSub/DVB) as not burn-in-able
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> |
|||
| a5aca4a823 |
Fix home page's party "elapsed" label ignoring pauses
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> |
|||
| 4a31ee741e |
Rewrite server + client in TypeScript, service-oriented server architecture
All checks were successful
Publier l'image Docker / publish (push) Successful in 18s
Full rewrite, requested directly. Two independent decisions since TypeScript can't compile itself away at runtime - "no build step" and "TypeScript" are mutually exclusive, not a small addition to the existing setup: - Server: plain tsc (no bundling need - Node resolves its own requires). server/dist mirrors server/src 1:1. - Client: esbuild (does need bundling) - picked over a heavier tool (Vite, webpack) since this app's whole client is a couple thousand lines; its built-in CSS-from-JS bundling and a `text` loader for importing an HTML template as a string cover everything needed via a ~30-line hand-written build script (client/build.mjs), not a config file for something built for much bigger apps. Both projects compile independently (no shared-types package - considered and rejected as more structural commitment than this app's size justifies; server/src/types.ts and client/src/types.ts have their own overlapping DTO copies instead). server/'s `npm run dev` uses tsx for a compile-free dev loop; `npm run build` (tsc) is what server/Dockerfile's build stage actually runs. client/'s `npm run watch` is esbuild's own watch mode. Server: one singleton service class per feature, generalizing the PartyManager/MusicQueueService pattern that already existed, rather than route handlers with logic inline: - AuthService (from embyAuth.js), CatalogService (NEW - previously ~15 route handlers' worth of Emby queries/DTO shaping inlined directly in index.js), PartyService (renamed from PartyManager), MusicQueueService (ported as-is, already service-shaped), SocketGateway (from socketHandler.js), EmbyClient (from embyProxy.js - low-level plumbing, not called a "service" since it has no state/feature of its own). - index.ts is now thin wiring only: every route reads the session, calls one service method, returns the result. - Real error-handling pass, not just a port: Express 4 doesn't catch a rejected Promise from an async handler on its own - a new asyncRoute() wrapper + a single error-handling middleware replace what used to be zero handling for a request that outright throws. CatalogService methods throw a CatalogFetchError (own HTTP status - getRawItem now preserves Emby's real 404 instead of a blanket 502) rather than touching res directly. Client: one folder per view (src/views/<name>/<Name>.ts + .html + .css) instead of one .js file with the markup inline as a template literal. A view's static shell moved to its .html (esbuild's text loader); per-item dynamic content (tile grids, episode/track lists) stays as template -literal-returning functions in the .ts, same as before. Genuinely shared CSS (tiles, the presence avatar, the series/album detail-header pattern, track-list rows) lives under src/styles/, imported by whichever views need it; src/styles/base.css holds the global primitives, imported once by app.ts. Deliberately not pushed to zero duplication/renaming shared classes across views for tidiness alone - not worth the risk in a change already this large. Player.ts keeps its pre-existing internal shape (the /party/:id dispatcher + the video renderer in one file, importing renderMusicPlayer from the sibling folder) - that pattern predates this rewrite and only needed types, not restructuring. Two real things the port itself caught, not just carried over: - A type bug: porting SocketGateway's "members" broadcast, reusing PartyService's PublicMember interface would have type-checked fine but was the wrong (larger) shape - the socket broadcast never included isPaused/positionTicks, only the REST response did. Caught by comparing against the original JS line by line, fixed with a dedicated SocketMemberDto type. - A real Express+TypeScript friction point: noUncheckedIndexedAccess (enabled in both tsconfigs - genuinely useful elsewhere, e.g. PartyService.advanceQueue's array indexing) makes every req.params.xxx read a type error, since Express's index-signature typing doesn't know a matched route's required :name segment is always present. Resolved once with a param(req, name) helper rather than 15 routes' worth of scattered assertions. Verified: both projects type-check cleanly in strict mode (tsc --noEmit - not just "esbuild didn't complain", which it wouldn't even with type errors present, since it transpiles without checking). Server: every unit/integration test from the JS version (party lifecycle logging, the music queue's timer/vote/pause behavior incl. real setTimeout auto-advance, the socket integration through SocketGateway+PartyService+ MusicQueueService together, the library-type filter, the playback -override forcing) re-run against the compiled server/dist output - identical results; the compiled server was actually started and confirmed listening cleanly. Client: since esbuild bundles everything into one app.js (no more individually-importable view modules), testing moved to driving the real compiled bundle through its actual boot -> login -> router -> view chain (mocked fetch/Socket.IO/hls.js, real clicks/hash navigation) - arguably more realistic than the old per-module approach. Confirmed this way: login, the sidebar, home's carousels, a video party (presence, host controls, leave), the full music hierarchy, a music party (presence, vote-skip, pause toggle, the inline browse-and-add panel), and the series page - all through client/dist's actual output, no thrown errors anywhere. Not verified: an actual `docker build` of the new multi-stage Dockerfile - this dev environment has no Docker at all. The build stage runs the exact npm run build commands already confirmed working locally, just inside node:20-alpine - the main remaining risk is something environment -specific, not the build commands themselves, but a real build is still the first thing to try after this lands. docs/architecture.md has the full writeup (new "TypeScript and a service-oriented architecture" section, plus a corrected top-level "Components" overview that had gone stale even before this change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |