Compare commits

..

9 commits

Author SHA1 Message Date
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
ff2b9e46f2 Merge pull request 'Report watch-party streams to Emby's own Sessions API (admin dashboard)' (#6) from feature/emby-sessions-dashboard into master
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 17s
Reviewed-on: #6
2026-09-16 07:14:11 +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
c141d0e8b7 Merge pull request 'Hide the cursor over the video once the bottom bar auto-hides' (#3) from fix/player-cursor-hide into master
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 16s
Reviewed-on: #3
2026-09-15 22:52:33 +02:00
a01e0fa86f Merge branch 'master' into fix/player-cursor-hide
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 7s
Publier l'image Docker / publish (pull_request) Has been skipped
2026-09-15 22:52:20 +02:00
1f0a441ca8 Merge pull request 'Fix real data loss: two catalog queries were silently truncating at 300' (#2) from fix/catalog-data-loss into master
Some checks failed
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Has been cancelled
Reviewed-on: #2
2026-09-15 22:52:10 +02:00
40e019cd81 Correct docs/comments left stale by the Limit removal
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
2026-09-15 22:51:36 +02:00
26d32632c7 Hide the cursor over the video once the bottom bar auto-hides
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 15s
Publier l'image Docker / publish (pull_request) Has been skipped
Asked for directly: the auto-hide behavior itself (bottom bar collapses
to just its seek line after a stretch of no activity) already shipped
earlier - a mouse cursor left sitting over the video afterward is the
same kind of clutter that was the point of hiding in the first place.

.playerWrap now gets a .cursorHidden class alongside #bottomBar's own
.minimal, toggled at the exact same two call sites
(scheduleControlsHide/showControls) so the two states can never drift
out of sync with each other. cursor: none on that class does the rest -
no new listener needed, since playerWrap's own pointermove/pointerdown
already call showControls() on any mouse activity, which is exactly
when the cursor should reappear too.

Verified: typecheck + build (client). Confirmed the compiled bundle
carries both the classList calls and the CSS rule. Not verified in an
actual running player - no Emby login reachable from this sandbox to
open a real video party and let the auto-hide timer elapse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 22:50:08 +02:00
05c4611403 Fix real data loss: two catalog queries were silently truncating at 300
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Reported right after the previous PR merged: "the catalogs are broken,
not all the data is there". Confirmed directly against the real Emby
server, not guessed - this app's own "Musique" library has 1229
distinct artists and its "Films" library has 421 movies. getArtists
gained a 300-item Limit in that same previous PR (meant defensively,
alongside the render-side fix below); getLibraryItems already had one,
apparently written before this - both were silently dropping the tail
of the list with no error and no visible sign anything was missing,
which is exactly what "some of the data is gone" looks like from the
outside.

Removed both. Confirmed empirically that omitting Limit entirely makes
Emby return everything (TotalRecordCount matched Items.length exactly
for both the 421-item and 1229-item cases, no server-side default cap
kicking in) - there was never a reason for either app-side number
beyond guarding the render path, and that guard already exists
elsewhere: tiles.ts's renderTilesInChunks (the previous PR's fix for
"the front freezes while it loads") inserts any size of list in small
batches regardless of how large it is.

While in there: renderTilesInChunks itself was scheduling its next
chunk with requestAnimationFrame, which browsers deliberately pause for
a backgrounded/hidden tab - completely ordinary to hit in practice
(switching tabs while a 1229-item list is still loading, which now
takes meaningfully longer than the 300-item case this was written and
tested against) and would have stalled the render indefinitely,
presenting as the exact same "data missing" symptom for a different
reason. Switched to setTimeout(fn, 0), which keeps running (only
throttled, never paused) in a background tab.

Verified: full typecheck/test/build on both projects (122/122 server
tests). The two real counts above (421, 1229) and the "no Limit means
Emby returns everything" behavior were both confirmed with live,
read-only requests against the real server - not assumed. Not
verified: an actual browser render of either list end to end (no Emby
login reachable from this sandbox), though the render mechanism itself
was already verified separately in the previous PR and is unchanged
here beyond the scheduling primitive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 22:48:44 +02:00
16 changed files with 564 additions and 41 deletions

View file

@ -332,6 +332,11 @@ Then put it behind your existing reverse proxy like everything else
native marker detection (`EnableMarkerDetection` + its "Detect
Episode Intros" task) rather than building fingerprinting
ourselves — see docs/architecture.md
- [x] Report each party member's own playback to Emby's real Sessions
API (`/Sessions/Playing`, `/Progress`, `/Stopped`), so the admin
dashboard sees watch-party streams the same way it sees a native
client's — one session per member, under their own account — see
docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session)

View file

@ -96,15 +96,26 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
}
/**
* Renders `items` into `container` a chunk at a time, across animation
* frames, instead of one `innerHTML` write for the whole list - a library
* grid can hand back up to 300 tiles at once (CatalogService.getLibraryItems/
* getArtists), and building + inserting/laying out ~300 posters in a
* single synchronous task is exactly what a "the page freezes while it
* loads" report looks like on a slower device. Splitting the same HTML
* across a few chunks keeps every individual task well under a frame's
* budget, so the tab stays responsive (and the loading spinner keeps
* animating) while it works through the rest.
* Renders `items` into `container` a chunk at a time instead of one
* `innerHTML` write for the whole list - a library grid can legitimately
* hand back over a thousand tiles now that CatalogService.getLibraryItems/
* getArtists no longer cap themselves (confirmed against the real server:
* one library alone has 1229 artists - see their own comments), and
* building + inserting/laying out that many in a single synchronous task
* is exactly what a "the page freezes while it loads" report looks like
* on a slower device. Splitting the same HTML across a few chunks keeps
* every individual task well under a frame's budget, so the tab stays
* responsive (and the loading spinner keeps animating) while it works
* through the rest.
*
* Scheduled with `setTimeout(fn, 0)`, not `requestAnimationFrame` - rAF
* callbacks are deliberately paused by the browser for a backgrounded/
* hidden tab, which is a completely ordinary thing to do while a large
* library is still loading (switching tabs, minimizing the window) and
* would otherwise stall the render indefinitely, looking exactly like
* "some of the data is missing" rather than "still loading". setTimeout
* keeps running in a background tab (only throttled, never paused), so
* this always finishes on its own.
*
* Uses `insertAdjacentHTML`, not repeated `innerHTML +=` (which would
* re-parse and re-render everything already inserted on every chunk).
@ -120,7 +131,7 @@ export function renderTilesInChunks(container: HTMLElement, items: ItemDto[], ch
if (!chunk.length) return;
container.insertAdjacentHTML('beforeend', chunk.map(tileHtml).join(''));
i += chunkSize;
if (i < items.length) requestAnimationFrame(renderNextChunk);
if (i < items.length) setTimeout(renderNextChunk, 0);
}
renderNextChunk();
}
@ -129,11 +140,14 @@ export function renderTilesInChunks(container: HTMLElement, items: ItemDto[], ch
* Wires a search <input> to show/hide `itemSelector` elements by name,
* live as you type - client-side only, filtering whatever's already
* rendered rather than a server round trip per keystroke. Deliberately
* simple for that reason: a library page tops out at a few hundred items
* (see getLibraryItems's own Limit), well within "just filter the DOM"
* territory - shared by views/library, views/artist, views/album so the
* three catalog pages that asked for this all behave identically instead
* of drifting into three slightly different implementations.
* simple for that reason: even a library with well over a thousand items
* (see getLibraryItems/getArtists - no Limit any more, see their own
* comments) is still just a plain, already-in-the-DOM list of elements
* to a querySelectorAll/textContent check - "just filter the DOM" scales
* fine here regardless of count. Shared by views/library, views/artist,
* views/album so the three catalog pages that asked for this all behave
* identically instead of drifting into three slightly different
* implementations.
*
* `scope` should be wide enough to contain both the items themselves AND
* a `.searchEmpty` notice element - typically the view's own `root`, not

View file

@ -16,6 +16,15 @@
object-fit: contain;
}
/* Toggled alongside .bottomBar.minimal (see Player.ts's scheduleControlsHide/
showControls) - a mouse cursor left sitting over the video is the same
kind of clutter the bar itself was, once there's nothing left under it
to interact with. Any pointermove/pointerdown anywhere in .playerWrap
already calls showControls(), so the cursor reappears the instant the
mouse moves again - same convention as any other video player's
auto-hiding controls. */
.playerWrap.cursorHidden { cursor: none; }
/* Two stacked rows, both part of the same top gradient overlay: the action
bar (title/members/next/settings/leave) and, below it, the presence
avatars - see .presenceOverlay in styles/presence.css. Stacking them in

View file

@ -583,7 +583,11 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
* sitting on the bar itself, and re-arms on every discrete playback
* event too (onDriverStateChanged above), not just raw pointer
* activity - resuming from pause has to restart the countdown, not
* leave the bar stuck open forever.
* leave the bar stuck open forever. .playerWrap also gets
* .cursorHidden alongside .minimal (see Player.css) - asked for
* directly too: a mouse cursor left sitting over the video is exactly
* the same kind of clutter the bar itself was, once there's nothing
* left under it to interact with.
*/
function clearControlsHideTimer() {
if (controlsHideTimer) {
@ -597,11 +601,13 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
controlsHideTimer = window.setTimeout(() => {
if (activeDriver.paused || isPointerOverBottomBar) return;
bottomBar.classList.add('minimal');
playerWrap.classList.add('cursorHidden');
}, CONTROLS_HIDE_DELAY_MS);
}
function showControls() {
bottomBar.classList.remove('minimal');
playerWrap.classList.remove('cursorHidden');
scheduleControlsHide();
}

View file

@ -2631,6 +2631,28 @@ mode a naive per-button-at-call-time wiring would have hit). **Not
verified**: no Emby login reachable from this sandbox to measure the
actual before/after latency against a real, large library.
**Correction, reported immediately after this shipped**: "the catalogs
are broken, not all the data is there". The `Limit: 300` both queries
above carried (`getArtists` gained one here; `getLibraryItems` already
had one, predating this section) turned out not to be the safe,
theoretical headroom it was assumed to be - confirmed directly against
the real server that this app's own "Musique" library has **1229**
distinct artists and "Films" has **421** movies, so both were silently
serving an incomplete grid, no error, no visible sign anything was
missing. Both `Limit`s removed outright, not raised to some other
guessed number - confirmed empirically that Emby returns everything
when `Limit` is omitted at all (`TotalRecordCount` matched
`Items.length` exactly for both the 421- and 1229-item cases), and the
render-side concern that motivated capping in the first place is
already `renderTilesInChunks`'s job regardless of how large the list
gets. That fix's own scheduling primitive needed a second look too:
`requestAnimationFrame` is deliberately paused by the browser for a
backgrounded/hidden tab - an ordinary thing to do while a now-possibly-
1000+-item list is still loading, and would have stalled the render
indefinitely, looking exactly like the same "data missing" symptom for
an unrelated reason. Switched to `setTimeout(fn, 0)`, which keeps
running (throttled, never paused) regardless of tab visibility.
## Fixing the listen-party queue's layout and its missing album art
Reported directly: the music bar's "File d'attente" popup looked broken,
@ -3177,6 +3199,97 @@ actual end-to-end "does a detected marker make the button appear at
the right second" behavior is unconfirmed against real generated data -
worth a real check once the task has had time to run.
## Making Emby's own admin dashboard see watch-party streams
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"
(above) already anticipated and deliberately deferred, word for word:
"wiring clean start/stop into every transition... is real complexity
for a secondary payoff, left as a documented follow-up rather than
built speculatively." That section also already explains why: this app
switched entirely to direct `UserData` writes for resume-position/
watched-history, abandoning Emby's session-shaped `PlayingItems`
endpoints, precisely because they need a real session lifecycle to mean
anything - and given that section's own history (two separate rounds of
a wrong-but-schema-plausible request shape for that *other*,
now-abandoned endpoint family), this one was built carefully, checking
the real API before writing anything rather than assuming.
**Confirmed against the real server's own `/openapi.json`** (not
assumed) that the actual session-lifecycle family is `POST
/Sessions/Playing` (start), `/Sessions/Playing/Progress`, and
`/Sessions/Playing/Stopped` - distinct from the `PlayingItems` family
`PlaystateService` already walked away from. None of the three schemas
declare a `required` list at all, which - given this exact neighboring
history - was read as "don't trust that," not "everything's optional
in practice": `ItemId`/`MediaSourceId`/`PlaySessionId`/`PositionTicks`
are sent on every call anyway, matching what a real client obviously
would, rather than risking a repeat of the earlier "technically matches
the schema, still 400s" experience.
**One real Emby session per PARTY MEMBER, not one per party** - a
faithful copy of how a real client behaves (every device/account gets
its own), and the actual point of "let admin see who's watching what"
would be lost by collapsing every member onto one shared entry. New
`server/src/services/EmbySessionService.ts` (`start`/`progress`/`stop`)
mirrors `PlaystateService`'s own shape closely - same best-effort
philosophy (every failure caught and logged, never thrown), same
`Logger.playstate` category (broadened to describe both) - but is kept
as its own class rather than folded into `PlaystateService`, since that
class's entire opening comment is specifically about why it *doesn't*
use a session-shaped endpoint; mixing the two would muddy exactly the
distinction that comment goes out of its way to draw.
**Getting a `MediaSourceId`/`PlaySessionId` per member** needed a new
`CatalogService.negotiatePlaybackSession(session, itemId)` - a fresh
`PlaybackInfo` negotiation under that specific member's own account.
This can't reuse `PartyStreamService`'s existing shared negotiation:
that one is negotiated once, under whichever member happened to
trigger it first, and Emby scopes `PlaybackInfo` per user - never
guaranteed valid for reporting under everyone else's own account too.
Returns `null` on any failure rather than throwing (an expired token,
Emby unreachable, no source) - unlike `getMediaSource`'s own hard
failure (which feeds a real ffmpeg source and has to fail loudly), this
one only ever feeds an admin-dashboard nicety.
**Driven entirely from index.ts's existing periodic sweep**, not new
hooks scattered across every queue-advance/track-skip call site the way
`PlaystateService.markPlayed` needed. That sweep already visits every
connected member on every ~20s tick - exactly positioned to notice
"this member's current item no longer matches `PartyMember
.embySession.itemId`" and react: stop the old session if there was
one, negotiate fresh ids, start the new one. The same item as last tick
just sends a Progress report reusing the already-cached ids - no
renegotiation needed for the common case. `PartyMember` gained the
nullable `embySession` field this reads/writes (same null-until-known
shape as `embyToken`/`deviceId`). Explicit `stop` calls also happen at
the two places a member's session should end promptly rather than
waiting on the next sweep tick to just stop reporting: `PartyService
.leave()` (alongside the existing `CastTokenService.revokeForMember`)
and `removeIdleParties` (a defensive backstop for a member whose own
disconnect-driven `leave()` never fired for some reason - a real gap
would otherwise leave a stale dashboard entry indefinitely, not just
until the next tick).
Verified: full `typecheck`/`test`/`build` (140/140 server tests - new
coverage for `negotiatePlaybackSession`'s null-on-any-failure
contract, every `EmbySessionService` method's exact request shape and
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 from
here, since doing so 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.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's

View file

@ -9,7 +9,7 @@
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"typecheck": "tsc -p tsconfig.test.json",
"test": "tsx --test test/castTokenService.test.ts test/catalogService.test.ts test/embyClient.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts"
"test": "tsx --test test/castTokenService.test.ts test/catalogService.test.ts test/embyClient.test.ts test/embySessionService.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts"
},
"engines": {
"node": ">=18"

View file

@ -66,17 +66,20 @@ export class Logger {
console.log(`[watchparty:transcode] ${new Date().toISOString()} ${message}`);
}
/** The party watch-state sync (index.ts's periodic sweep, and
* PartyService.advanceQueue/MusicQueueService.playNext's own item-
* transition calls - see PlaystateService) - logs every outgoing
* request (method + path, and the POST body for the UserData write)
* and its outcome, not just failures: this is a best-effort
* background sync with nothing else watching it, so `docker logs |
* grep watchparty:playstate` needs to be enough on its own to see
* exactly what was sent and whether Emby accepted it, without cross-
* referencing `emby` above. Safe to log in full here (unlike `stream`
* above) - the real embyToken travels as a header, never logged, and
* PlaystateService's own tests confirm it never leaks into these lines. */
/** Every per-member background write this app makes to Emby under
* someone's own account - the party watch-state sync (index.ts's
* periodic sweep, and PartyService.advanceQueue/MusicQueueService
* .playNext's own item-transition calls - see PlaystateService), and
* the parallel Sessions-API lifecycle reported alongside it (see
* EmbySessionService). Logs every outgoing request (method + path,
* and the POST body for the UserData write) and its outcome, not
* just failures: this is a best-effort background sync with nothing
* else watching it, so `docker logs | grep watchparty:playstate`
* needs to be enough on its own to see exactly what was sent and
* whether Emby accepted it, without cross-referencing `emby` above.
* Safe to log in full here (unlike `stream` above) - the real
* embyToken travels as a header, never logged, and both services'
* own tests confirm it never leaks into these lines. */
static playstate(message: string): void {
console.log(`[watchparty:playstate] ${new Date().toISOString()} ${message}`);
}

View file

@ -14,6 +14,7 @@ import { MusicQueueService } from './services/MusicQueueService';
import { TranscodeService } from './services/TranscodeService';
import { PartyStreamService } from './services/PartyStreamService';
import { PlaystateService } from './services/PlaystateService';
import { EmbySessionService } from './services/EmbySessionService';
import { CastTokenService } from './services/CastTokenService';
import { SocketGateway } from './realtime/SocketGateway';
import type { ClientToServerEvents, EmbySession, MediaType, QueueItem, ServerToClientEvents } from './types';
@ -613,6 +614,17 @@ setInterval(() => PartyStreamService.reapIdleSessions(30 * 60 * 1000), 5 * 60 *
// Marking an item Played (once the party moves past it) is handled
// separately, at the source of that transition - see
// PartyService.advanceQueue and MusicQueueService.playNext.
//
// Also where EmbySessionService's own start/progress/stop lifecycle is
// driven from, rather than scattered across every queue-advance/track-
// skip call site the way markPlayed needed to be - this loop already
// visits every connected member on every tick, so it's already in
// exactly the right place to notice "this member's current item just
// changed" (member.embySession.itemId no longer matches) and react:
// stop the old session if there was one, negotiate a fresh
// MediaSourceId/PlaySessionId under their own account, and start the
// new one. Same item as last tick just reports Progress with the
// already-cached ids - no renegotiation needed for that, the common case.
setInterval(() => {
for (const party of PartyService.listActive()) {
const state = PartyService.getCurrentPlaybackState(party);
@ -620,6 +632,35 @@ setInterval(() => {
for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue;
PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
if (member.embySession && member.embySession.itemId === state.itemId) {
const s = member.embySession;
EmbySessionService.progress(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, state.positionTicks, state.isPaused);
continue;
}
// The item just changed for them (or this is the first tick since
// they started watching/listening) - stop whatever they had open
// before, if anything, then negotiate and start a fresh one.
const previous = member.embySession;
const embyToken = member.embyToken;
member.embySession = null;
if (previous) {
EmbySessionService.stop(embyToken, previous.itemId, previous.mediaSourceId, previous.playSessionId, member.positionTicks);
}
// CatalogService's own methods all take a full EmbySession, for
// consistency with every live user-facing route that already has
// one from requireSession - this sweep only ever has a member's
// bare stored token, same situation PlaystateService/
// EmbySessionService are already in. serverId/createdUtc are safe
// to fake: negotiatePlaybackSession (like every per-member Emby
// write in this app) never reads either, only embyToken/userId.
const fakeSession: EmbySession = { embyToken, userId: member.userId, userName: member.userName, serverId: '', createdUtc: 0 };
CatalogService.negotiatePlaybackSession(fakeSession, state.itemId).then((negotiated) => {
if (!negotiated) return;
member.embySession = { itemId: state.itemId, mediaSourceId: negotiated.mediaSourceId, playSessionId: negotiated.playSessionId };
EmbySessionService.start(embyToken, state.itemId, negotiated.mediaSourceId, negotiated.playSessionId, state.positionTicks, state.isPaused);
});
}
}
}, 20 * 1000);

View file

@ -199,11 +199,13 @@ class CatalogServiceClass {
// structure, same reasoning as Shows -> Seasons -> Episodes above.
async getArtists(session: EmbySession, libraryId: string): Promise<ItemDto[]> {
// Limit added for the same reason as getLibraryItems's own - unbounded
// on a large music library, this used to hand back every artist at
// once for the client to render in a single pass (see tiles.ts's
// renderTilesInChunks for the render-side half of that fix).
const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName', Limit: '300' });
// No Limit, deliberately - see getLibraryItems's own comment below for
// why one very nearly shipped here and got pulled: confirmed directly
// against the real server that this library alone has 1229 artists,
// so a 300 cap would have silently dropped 75% of them, not just
// trimmed an edge case. Rendering that many is tiles.ts's
// renderTilesInChunks's job, not this query's.
const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName' });
const data = await this.fetchJson<EmbyItemsResponse>(`/Artists?${params}`, session, 'Impossible de récupérer les artistes.');
return (data.Items || []).map((a) => this.toItemDto(a, session.serverId));
}
@ -238,17 +240,29 @@ class CatalogServiceClass {
// renders tileHtml's poster/name/year (views/library, tiles.ts), which
// never reads it. Traced here after "the catalog API is too slow, the
// front freezes while it loads" - Emby happily serializes a full
// synopsis for all 300 items, and the client happily JSON.parses all
// of it, entirely to be thrown away unread. Contrast getSeriesEpisodes/
// getPlaylistItemsRaw, which keep Overview because their own views
// (Series/Playlist) actually display it.
// synopsis for every item anyway, and the client happily JSON.parses
// all of it, entirely to be thrown away unread. Contrast
// getSeriesEpisodes/getPlaylistItemsRaw, which keep Overview because
// their own views (Series/Playlist) actually display it.
//
// No Limit - there used to be one here (300), presumably meant as the
// same defensive cap getArtists briefly grew (see its own comment).
// Confirmed directly against the real server that this app's own
// "Films" library alone has 421 items - past that cap, so it was
// quietly serving an incomplete grid with no error and no hint
// anything was missing, reported as "the catalog is broken, not all
// the data is there". Emby returns everything when Limit is omitted
// (confirmed: TotalRecordCount matched Items.length exactly with no
// Limit param, for both this library and the 1229-artist one above) -
// rendering that many tiles without freezing the tab is
// renderTilesInChunks's job (tiles.ts), not something this query
// should be truncating data to work around.
const params = new URLSearchParams({
ParentId: libraryId,
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
SortBy: 'SortName',
Fields: 'ProductionYear,AlbumId',
ImageTypeLimit: '1',
Limit: '300'
ImageTypeLimit: '1'
});
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette bibliothèque.');
return (data.Items || []).map((i) => this.toItemDto(i, session.serverId));
@ -317,6 +331,33 @@ class CatalogServiceClass {
return { id: source.Id, container: source.Container || 'mkv', streams: source.MediaStreams || [] };
}
/**
* A fresh PlaybackInfo negotiation under `session`'s own account,
* purely to get the MediaSourceId/PlaySessionId EmbySessionService
* needs to open a real Emby Sessions-API entry for this member - see
* its own class comment for why every party member needs their own,
* rather than reusing the one PartyStreamService already negotiated
* for the party's actual shared relay (that one was negotiated once,
* under whichever member happened to trigger it first - never
* guaranteed valid for every OTHER member's own account, since Emby
* scopes PlaybackInfo per user). Null on any failure (an expired
* token, Emby unreachable, no source at all) rather than throwing -
* this is reported to an admin dashboard as a nice-to-have, never
* something that should be allowed to disrupt real playback the way
* getMediaSource's own hard failure is allowed to (that one feeds an
* actual ffmpeg source).
*/
async negotiatePlaybackSession(session: EmbySession, itemId: string): Promise<{ mediaSourceId: string; playSessionId: string } | null> {
try {
const info = await this.fetchJson<PlaybackInfoResponse>(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session, 'Impossible de négocier une session de lecture.');
const source = (info.MediaSources || [])[0];
if (!source?.Id || !info.PlaySessionId) return null;
return { mediaSourceId: source.Id, playSessionId: info.PlaySessionId };
} catch {
return null;
}
}
// --- Direct proxying (images/video/audio) - these need the raw
// Express req/res, unlike everything above which just returns JSON. ---

View file

@ -0,0 +1,104 @@
'use strict';
import { EmbyClient } from '../emby/EmbyClient';
import { Logger } from '../Logger';
/**
* Reports a party member's own playback to Emby's real Sessions API
* (`POST /Sessions/Playing`, `/Sessions/Playing/Progress`,
* `/Sessions/Playing/Stopped`) - the piece of session lifecycle
* docs/architecture.md's "Sync watch-party progress back to Emby"
* section deliberately deferred at the time ("wiring clean start/stop
* into every transition... is real complexity for a secondary payoff").
* Asked for directly since: without this, this app's own streams never
* showed up in Emby's admin "now playing" dashboard the way a real web/
* mobile/TV client's would - only the resume-position/watched-history
* UserData (PlaystateService) was ever visible anywhere, and only by
* opening the item itself, not from the dashboard's live session list.
*
* One real Emby session per PARTY MEMBER, not one per party - a
* faithful copy of how a real client behaves (every device/account gets
* its own), and the actual point of the request ("let admin see who's
* watching what") would be lost by collapsing every member onto one
* shared entry. Every call needs the member's own `embyToken` (the same
* source every other per-member Emby write in this app already uses -
* see `PartyMember.embyToken`/`PlaystateService`) - Emby's Sessions API
* is inherently per-account, there's no equivalent of "the party's" own
* session to report against instead.
*
* `MediaSourceId`/`PlaySessionId` (see `CatalogService
* .negotiatePlaybackSession`) are required in practice, not just
* present in the schema - confirmed by reading the real server's own
* `/openapi.json` rather than assumed, after this exact neighboring
* area (`PlaystateService`'s own history) already got bitten twice by
* an Emby endpoint accepting a request shape that looked complete but
* wasn't. `index.ts`'s periodic sweep is what actually drives the
* start/progress/stop transitions - see its own comment for why that
* one place could cover this instead of scattering hooks across every
* queue-advance/track-skip call site the way `PlaystateService
* .markPlayed` needed to.
*
* Best-effort throughout, exactly like `PlaystateService`: any failure
* is caught and logged here, never thrown - none of this may ever
* disrupt real playback over an admin-dashboard nicety.
*/
class EmbySessionServiceClass {
/** Called once per member when they start watching/listening to
* `itemId` - a fresh join, or the party moving to a new item while
* they're already a member (see index.ts's sweep, which detects that
* by comparing against `PartyMember.embySession.itemId`). */
async start(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise<void> {
await this.report('/Sessions/Playing', embyToken, {
ItemId: itemId,
MediaSourceId: mediaSourceId,
PlaySessionId: playSessionId,
PositionTicks: Math.round(positionTicks),
IsPaused: isPaused,
PlayMethod: 'Transcode',
CanSeek: true
});
}
/** Called periodically (~20s, alongside `PlaystateService
* .updatePosition` - see index.ts's sweep) for every member with a
* live session already open for their current item. */
async progress(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number, isPaused: boolean): Promise<void> {
await this.report('/Sessions/Playing/Progress', embyToken, {
ItemId: itemId,
MediaSourceId: mediaSourceId,
PlaySessionId: playSessionId,
PositionTicks: Math.round(positionTicks),
IsPaused: isPaused,
PlayMethod: 'Transcode'
});
}
/** Called once per member when they stop watching/listening to
* `itemId` - leaving the party (`PartyService.leave`), the item
* changing (index.ts's sweep), or the party being destroyed for
* inactivity (`PartyService.removeIdleParties`). */
async stop(embyToken: string, itemId: string, mediaSourceId: string, playSessionId: string, positionTicks: number): Promise<void> {
await this.report('/Sessions/Playing/Stopped', embyToken, {
ItemId: itemId,
MediaSourceId: mediaSourceId,
PlaySessionId: playSessionId,
PositionTicks: Math.round(positionTicks)
});
}
/** Every call logs its own outgoing request + outcome - see
* Logger.playstate's own comment for why this needs to be enough on
* its own, greppable, to answer "is this actually reaching Emby, and
* with what" for this service too, not just PlaystateService. */
private async report(path: string, embyToken: string, body: Record<string, unknown>): Promise<void> {
Logger.playstate(`-> POST ${path} itemId=${body.ItemId}`);
try {
const res = await EmbyClient.fetch(path, embyToken, { method: 'POST', body });
Logger.playstate(`<- ${res.status} ${res.ok ? 'ok' : 'FAILED'} ${path} itemId=${body.ItemId}`);
} catch (err) {
Logger.playstate(`<- threw ${path} itemId=${body.ItemId}: ${(err as Error).message}`);
}
}
}
export const EmbySessionService = new EmbySessionServiceClass();

View file

@ -7,6 +7,7 @@ import { TranscodeService } from './TranscodeService';
import { PartyStreamService } from './PartyStreamService';
import { PlaystateService } from './PlaystateService';
import { CastTokenService } from './CastTokenService';
import { EmbySessionService } from './EmbySessionService';
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
/**
@ -157,6 +158,15 @@ class PartyServiceClass {
party.lastActivityUtc = Date.now();
MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset)
CastTokenService.revokeForMember(party.id, userId); // no-op if they never cast - see its own comment for why this runs unconditionally, not just on an explicit "stop casting"
// No-op if they never had a live Emby Sessions-API entry (never
// connected, or the periodic sweep hadn't reached them yet) - see
// EmbySessionService's own comment. Fire-and-forget, same as every
// other per-member Emby report in this app - it already swallows its
// own failures, and leaving the party must never wait on Emby.
if (leavingMember?.embyToken && leavingMember.embySession) {
const s = leavingMember.embySession;
EmbySessionService.stop(leavingMember.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, leavingMember.positionTicks);
}
let newHostUserId: string | null = party.hostUserId;
if (wasHost) {
@ -256,6 +266,17 @@ class PartyServiceClass {
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
TranscodeService.endSession(party.id); // same leak - see the "leave" destroy path above
PartyStreamService.endSession(party.id);
// Same reasoning, for any member whose own "leave" (a clean
// disconnect) never fired for whatever reason - a real gap would
// otherwise leave a stale entry sitting in Emby's own dashboard
// indefinitely, not just until the next sweep tick the way a
// still-connected member's stale Progress report would.
for (const member of party.members.values()) {
if (member.embyToken && member.embySession) {
const s = member.embySession;
EmbySessionService.stop(member.embyToken, s.itemId, s.mediaSourceId, s.playSessionId, member.positionTicks);
}
}
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
Logger.party(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
}
@ -311,7 +332,8 @@ class PartyServiceClass {
embyToken: null,
isPaused: true,
positionTicks: 0,
lastUpdatedUtc: Date.now()
lastUpdatedUtc: Date.now(),
embySession: null
};
}
}

View file

@ -274,6 +274,22 @@ export interface PartyMember {
lastUpdatedUtc: number;
lastPauseRequestUtc?: number;
lastResumeRequestUtc?: number;
/** This member's own live Emby Sessions-API entry, if one's currently
* open - see EmbySessionService. Null whenever they don't have one
* (not connected/no token yet, or the periodic sweep hasn't started
* one for their current item yet). `itemId` is what the sweep compares
* against on each tick to notice an item change (stop the old
* session, start a fresh one) - `mediaSourceId`/`playSessionId` are
* cached here specifically so a Progress report can reuse them
* without renegotiating PlaybackInfo on every single tick. */
embySession: EmbyPlaybackSession | null;
}
/** See PartyMember.embySession's own comment. */
export interface EmbyPlaybackSession {
itemId: string;
mediaSourceId: string;
playSessionId: string;
}
/** The full internal party record - never sent to the client as-is, see

View file

@ -205,6 +205,35 @@ describe('CatalogService', () => {
);
});
it('negotiatePlaybackSession returns the MediaSourceId/PlaySessionId a Sessions-API report needs', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({
MediaSources: [{ Id: 'src1' }],
PlaySessionId: 'play1'
}));
const result = await CatalogService.negotiatePlaybackSession(session, 'item1');
assert.deepEqual(result, { mediaSourceId: 'src1', playSessionId: 'play1' });
});
it('negotiatePlaybackSession returns null, not a throw, when Emby has no media source at all', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [], PlaySessionId: 'play1' }));
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
});
it('negotiatePlaybackSession returns null when Emby omits PlaySessionId, even with a real source', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => jsonResponse({ MediaSources: [{ Id: 'src1' }] }));
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
});
it('negotiatePlaybackSession returns null, not a throw, when the negotiation itself fails', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 500 }) as unknown as Response);
assert.equal(await CatalogService.negotiatePlaybackSession(session, 'item1'), null);
});
it('getPlaylistItemsRaw asks Emby\'s own /Playlists endpoint, in order', async (t) => {
let capturedPath = '';
t.mock.method(EmbyClient, 'fetch', async (path: string) => {

View file

@ -0,0 +1,91 @@
// Exercises EmbySessionService directly against a mocked EmbyClient - no
// real Emby server involved. Covers the request shape each of the three
// Sessions-API calls sends and that a failure (a non-ok response, or a
// thrown error) is swallowed rather than propagated - see the class's
// own comment for why none of this may ever be allowed to disrupt real
// playback over an admin-dashboard nicety.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { EmbySessionService } from '../src/services/EmbySessionService';
import { EmbyClient } from '../src/emby/EmbyClient';
function okResponse(): Response {
return new Response(null, { status: 204 }) as unknown as Response;
}
describe('EmbySessionService', () => {
it('start() POSTs to /Sessions/Playing with the negotiated ids and the given position/pause state', async (t) => {
let capturedPath = '';
let capturedToken = '';
let capturedBody: unknown = null;
t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: { body?: unknown }) => {
capturedPath = path;
capturedToken = token;
capturedBody = options.body;
return okResponse();
});
await EmbySessionService.start('tok1', 'item1', 'src1', 'play1', 12_345, false);
assert.equal(capturedPath, '/Sessions/Playing');
assert.equal(capturedToken, 'tok1');
assert.deepEqual(capturedBody, {
ItemId: 'item1',
MediaSourceId: 'src1',
PlaySessionId: 'play1',
PositionTicks: 12_345,
IsPaused: false,
PlayMethod: 'Transcode',
CanSeek: true
});
});
it('progress() POSTs to /Sessions/Playing/Progress', async (t) => {
let capturedPath = '';
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
capturedPath = path;
return okResponse();
});
await EmbySessionService.progress('tok1', 'item1', 'src1', 'play1', 999, true);
assert.equal(capturedPath, '/Sessions/Playing/Progress');
});
it('stop() POSTs to /Sessions/Playing/Stopped', async (t) => {
let capturedPath = '';
let capturedBody: unknown = null;
t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options: { body?: unknown }) => {
capturedPath = path;
capturedBody = options.body;
return okResponse();
});
await EmbySessionService.stop('tok1', 'item1', 'src1', 'play1', 42);
assert.equal(capturedPath, '/Sessions/Playing/Stopped');
assert.deepEqual(capturedBody, { ItemId: 'item1', MediaSourceId: 'src1', PlaySessionId: 'play1', PositionTicks: 42 });
});
it('rounds a fractional PositionTicks rather than sending a non-integer', async (t) => {
let capturedBody: { PositionTicks?: number } = {};
t.mock.method(EmbyClient, 'fetch', async (_path: string, _token: string, options: { body?: unknown }) => {
capturedBody = options.body as { PositionTicks?: number };
return okResponse();
});
await EmbySessionService.progress('tok1', 'item1', 'src1', 'play1', 12_345.6, false);
assert.equal(capturedBody.PositionTicks, 12_346);
});
it('a non-ok response is logged, not thrown', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => new Response(null, { status: 401 }) as unknown as Response);
await assert.doesNotReject(() => EmbySessionService.start('tok1', 'item1', 'src1', 'play1', 0, false));
});
it('a thrown network error is logged, not propagated', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('network down'); });
await assert.doesNotReject(() => EmbySessionService.stop('tok1', 'item1', 'src1', 'play1', 0));
});
});

View file

@ -49,7 +49,8 @@ function fakeMember(userId: string, userName: string, embyToken: string): PartyM
embyToken,
isPaused: false,
positionTicks: 0,
lastUpdatedUtc: Date.now()
lastUpdatedUtc: Date.now(),
embySession: null
};
}

View file

@ -4,6 +4,7 @@ import { PartyService } from '../src/services/PartyService';
import { MusicQueueService } from '../src/services/MusicQueueService';
import { PlaystateService } from '../src/services/PlaystateService';
import { CastTokenService } from '../src/services/CastTokenService';
import { EmbySessionService } from '../src/services/EmbySessionService';
import type { AppSocket } from '../src/types';
describe('PartyService', () => {
@ -127,6 +128,33 @@ describe('PartyService', () => {
assert.deepEqual(calls, [[party.id, 'u-bob']]);
});
it('leave() stops whatever Emby Sessions-API entry the leaving member had open', (t) => {
const calls: unknown[][] = [];
t.mock.method(EmbySessionService, 'stop', async (...args: unknown[]) => { calls.push(args); });
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const bob = PartyService.join(party, 'u-bob', 'Bob');
bob.embyToken = 'tok-bob';
bob.positionTicks = 555;
bob.embySession = { itemId: 'm1', mediaSourceId: 'src1', playSessionId: 'play1' };
PartyService.leave(party, 'u-bob');
assert.deepEqual(calls, [['tok-bob', 'm1', 'src1', 'play1', 555]]);
});
it('leave() does not call EmbySessionService.stop for a member with no open session', (t) => {
let called = false;
t.mock.method(EmbySessionService, 'stop', async () => { called = true; });
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob'); // embyToken/embySession both still null - see newMember
PartyService.leave(party, 'u-bob');
assert.equal(called, false);
});
it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
const bob = PartyService.join(party, 'u-bob', 'Bob');