Sync watch-party progress back to Emby (PlaystateService)
All checks were successful
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Successful in 16s

This app has been read/proxy-only toward Emby so far. Queued right
after the playlist feature, asked for directly: while someone's in a
party, push their progress to Emby's own PlaystateService, so their own
Emby library (resume position, last-played state) stays in sync with
the watch/listen party, under their own account - not just the host's,
and not just this app's own memory of it.

Scoped to periodic progress reporting only for v1 - no explicit
start/stop calls (Emby's own "now playing" list won't show this app's
streams, and "mark fully watched" isn't guaranteed the way a clean Stop
near 100% would trigger it). Steady Progress calls alone already cover
the actual ask; wiring clean start/stop into every transition (item
change, leave, party destroy, a device takeover) is real complexity for
a secondary payoff - left as a documented follow-up.

- server/src/types.ts: PartyMember gains embyToken (null until known,
  same shape as this session's own deviceId addition) - never sent to
  the client, toPublicState's member mapping doesn't grow it.
- server/src/emby/EmbyClient.ts: fetch() now optionally takes
  method/body (JSON-encoded once, here) - every existing GET-only call
  site unaffected, both are optional.
- server/src/services/PlaystateService.ts (new): reportProgress(token,
  userId, itemId, positionTicks, isPaused) - POSTs to
  /Users/{userId}/PlayingItems/{itemId}/Progress. Catches and logs its
  own failures (new Logger.playstate) rather than throwing - this runs
  on a timer with nothing to catch a rejection, and one bad token or a
  transient Emby hiccup must never touch real playback.
- server/src/services/PartyService.ts: new setMemberEmbyToken (records
  a member's own token) and getCurrentPlaybackState (video: the host's
  own tracked position - there's no per-follower tracking today, a
  reasonable approximation given the player's own drift correction;
  music: the one shared, server-authoritative position every member
  already sees the same way). The one real piece of logic here is pure
  and unit-tested on its own, same shape as this session's
  absoluteToRelativeIndex/splitPlaylistByMediaType.
- server/src/realtime/SocketGateway.ts: attach()'s getSession callback
  widened to expose embyToken - it was already being passed the full
  EmbySession (AuthService.getSession), just typed down to {userId,
  userName} before anything downstream could read the rest. The "join"
  handler now also records member.embyToken alongside deviceId.
- server/src/index.ts: setMemberEmbyToken called after each of the 4
  REST create/join call sites, so a member has a valid token from the
  moment they're a member, not only once their socket first connects.
  New ~20s setInterval (same shape as the existing removeIdleParties/
  TranscodeService.reapIdleSessions timers): for every active party,
  getCurrentPlaybackState once, then reportProgress for every connected
  member with a known token.
- server/test/embyClient.test.ts, partyService.test.ts,
  playstateService.test.ts (new): the new body-encoding path (and that
  a plain GET stays untouched), getCurrentPlaybackState's video/music/
  empty branches, setMemberEmbyToken, and that reportProgress's request
  shape is right and a thrown failure never propagates.
- docs/architecture.md, README.md: design + the deliberate v1 scope and
  the follower-position approximation spelled out.

No client changes - this is a pure server-side background process with
nothing for the browser to render or react to.

Verified: full typecheck+test+build on both projects (71/71 server
tests, 9 new). No Emby server is reachable from this dev environment,
so the actual endpoint/body shape is unverified end-to-end - flagged
explicitly in the docs, the third such Emby-API assumption this session
(after the ffmpeg direct-stream URL and the Playlists endpoint, both
fine once actually tried).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-14 19:21:16 +02:00
parent b45a243e59
commit 11f003d269
14 changed files with 373 additions and 6 deletions

View file

@ -55,6 +55,10 @@ including why this replaced an earlier Emby-plugin attempt (still in
inside, then "Lancer cette playlist" starts one party pre-loaded with
everything in it — a video queue if it's movies/episodes, or the
participative music queue if it's tracks.
9. While you're in a party, your own progress is quietly pushed back to
your own Emby account (resume position, last-played state) — so
opening Emby's own app afterward reflects what you actually watched
or listened to, not just this app's own memory of it.
Everything — catalog, images, video, audio, sync — flows through this
app's own backend, which proxies whatever it needs from Emby server-side.
@ -96,7 +100,11 @@ conversion, session lifecycle) is unit-tested with `child_process.spawn`
mocked, the actual command against a real PGS file - and the exact
shape of Emby's direct-stream URL it depends on - has never run for
real. Treat the first real attempt at a Blu-ray-style subtitle as the
thing to double-check here too.
thing to double-check here too. Same again for the Emby-playstate sync
(the exact `PlaystateService` endpoint/body shape) and the Playlists
endpoint - three Emby-API assumptions total now waiting on a real
server to actually confirm, all otherwise built and unit-tested the
same way.
## Project layout
@ -241,6 +249,8 @@ Then put it behind your existing reverse proxy like everything else
party, host-driven — see docs/architecture.md
- [x] Browse Emby playlists and launch one as a pre-queued party (video
queue or the participative music queue) — see docs/architecture.md
- [x] Sync watch-party progress back to Emby's own PlaystateService, per
member's own account — see docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Tune transcoding target / support direct play when possible

View file

@ -1889,6 +1889,94 @@ video player for a video result (the same standing caveat as everywhere
else applies past that point - no real Emby server reachable here to
confirm actual playback).
## Sync watch-party progress back to Emby (PlaystateService)
Everything before this section reads from and proxies Emby; nothing
ever wrote anything back. Asked for directly: while someone's in a
party, push their progress to Emby's own `PlaystateService` API (its own
controller name in both Emby's and Jellyfin's server source) so their
own Emby library - resume position, last-played state - stays in sync
with what happened in the party, under *their own* account, not just
this app's.
**Scope, deliberately narrower than the full lifecycle**: only the
periodic `POST /Users/{UserId}/PlayingItems/{Id}/Progress` call is made.
No explicit start (`POST .../PlayingItems/{Id}`) or stop
(`DELETE .../PlayingItems/{Id}`) calls - Emby's own "now playing"
session dashboard won't show this app's streams, and the precise "mark
fully watched" heuristic (normally tied to a clean Stop near 100%) won't
fire deterministically. Steady Progress calls alone still update resume
position and last-played state either way, which is the actual core of
what was asked; 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 rather than built
speculatively.
**The one real gap this ran into**: reporting a *follower's* own
progress needs their own position, but video parties only ever track
the *host's* (`PartyMember.isPaused`/`positionTicks` are written solely
by the host's own `"playstate"` handler - see "Realtime transport"
above). Building genuine per-follower position tracking would be a real
protocol addition of its own. Instead, every member's video report uses
the host's tracked position - already what their own player is supposed
to closely track via its drift correction (see "Player: no page
scroll"/`applyRemoteState`), so this is a reasonable approximation, not
a hack that quietly reports the wrong thing. Music has no such gap at
all: `party.music.current` plus its elapsed time is already one shared,
server-authoritative position every member already sees the same way -
nothing to approximate there.
**Getting each member's own token to where it's needed** turned out to
already be most of the way there. `SocketGateway.attach`'s `getSession`
callback is wired (`index.ts`) to `AuthService.getSession`, which
already returns the *full* `EmbySession` (including `embyToken`) - the
callback's own parameter type just narrowed it down to `{userId,
userName}` before anything downstream could read the rest. Widening
that type was the entire fix. `PartyMember` gained `embyToken: string |
null` (same null-until-known shape as this session's own `deviceId`
addition) - set in the socket `"join"` handler alongside `deviceId`, and
via a new, small `PartyService.setMemberEmbyToken(party, userId,
embyToken)` called right after each of the 4 REST create/join call
sites in `index.ts`, so a member has a valid token from the moment
they're a member, not only once their socket first connects. Never sent
to the client - `PartyService.toPublicState`'s member mapping never
included `deviceId` either, and doesn't grow this one.
**One new pure, tested function carries the real logic**:
`PartyService.getCurrentPlaybackState(party)` - video vs. music, per the
Approximation note above - null when there's genuinely nothing to
report (an empty music queue). Same "the interesting bit gets its own
tested function, the timer loop around it stays thin and untested" shape
this session already used twice (`absoluteToRelativeIndex`,
`splitPlaylistByMediaType`).
**Calling Emby** needed `EmbyClient.fetch` to grow beyond
GET-with-headers - it now optionally takes `method`/`body`
(JSON-encoded here, once, so every caller passes a plain object), every
existing call site unaffected since both are optional. New
`server/src/services/PlaystateService.ts` wraps the one call this needs.
Failures are caught and logged (new `Logger.playstate`) rather than
thrown - this runs on a plain `setInterval` (~20s, alongside
`removeIdleParties`/`TranscodeService.reapIdleSessions`'s own timers)
with no request to fail and no business ever disrupting real playback
over a background sync hiccup.
**TODO(verify)**: the exact endpoint/body shape is unconfirmed against
a real Emby server - there isn't one reachable from this dev
environment, the same standing caveat as this session's other two
Emby-API assumptions (the ffmpeg direct-stream URL, the Playlists
endpoint), both of which turned out correct once actually tried.
Verified: full typecheck/test/build on both projects. Unit tests cover
`getCurrentPlaybackState` (video reads the host, music reads the shared
queue, both branches, plus the empty-queue null case),
`setMemberEmbyToken`, `PlaystateService.reportProgress`'s exact
request shape and that a thrown failure never propagates, and
`EmbyClient.fetch`'s new body-encoding path (plus a check that a plain
GET, still every other call site, never grows a stray `Content-Type`).
No client changes at all - this is a pure server-side background
process with nothing for the browser to render or react to.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's
@ -1917,3 +2005,11 @@ confirm actual playback).
transcode for every other stream. Its direct-source URL is also the
one Emby-shaped assumption in this whole app that's never been
confirmed against a real server.
- The Emby-playstate sync (see its own section above) only ever reports
*progress*, never an explicit start/stop - Emby's "now playing"
session list won't show this app's streams, and "mark fully watched"
isn't guaranteed to fire the way a clean Stop near 100% would trigger
it. A video follower's reported position is the *host's* tracked
position, not their own exact one (there's no per-follower tracking
today) - a reasonable approximation given the player's own drift
correction, not a precise per-viewer number.

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/catalogService.test.ts test/embyClient.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts"
"test": "tsx --test test/catalogService.test.ts test/embyClient.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts"
},
"engines": {
"node": ">=18"

View file

@ -65,4 +65,11 @@ export class Logger {
static transcode(message: string): void {
console.log(`[watchparty:transcode] ${new Date().toISOString()} ${message}`);
}
/** The periodic Emby-playstate sweep (index.ts / PlaystateService) -
* best-effort background sync, so this is where a failed report ends
* up instead of ever propagating - see PlaystateService's own comment. */
static playstate(message: string): void {
console.log(`[watchparty:playstate] ${new Date().toISOString()} ${message}`);
}
}

View file

@ -15,13 +15,23 @@ import { Logger } from '../Logger';
export class EmbyClient {
/** Fetches a path from Emby with the given user's token, forwarding
* Range (needed for direct-play video seeking) when the browser sent one.
* `method`/`body` are for PlaystateService's own POST calls (see
* PlaystateService.ts) - every other existing call site only ever
* passed `range`, so this stays a plain GET for them, unaffected.
* `body`, when given, is JSON-encoded here so every caller passes a
* plain object rather than remembering to stringify it themselves.
* Every Emby call in this app goes through here - see logErrorResponse
* below for what happens when Emby answers with anything but success. */
static async fetch(pathAndQuery: string, token: string, options: { range?: string } = {}): Promise<globalThis.Response> {
static async fetch(pathAndQuery: string, token: string, options: { range?: string; method?: string; body?: unknown } = {}): Promise<globalThis.Response> {
const url = pathAndQuery.startsWith('http') ? pathAndQuery : `${EMBY_SERVER_URL}${pathAndQuery}`;
const headers: Record<string, string> = { 'X-Emby-Token': token };
if (options.range) headers.Range = options.range;
const res = await fetch(url, { headers });
let body: string | undefined;
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(options.body);
}
const res = await fetch(url, { headers, method: options.method, body });
if (!res.ok) await EmbyClient.logErrorResponse(res, pathAndQuery);
return res;
}

View file

@ -12,6 +12,7 @@ import { CatalogService, CatalogFetchError, splitPlaylistByMediaType } from './s
import { PartyService } from './services/PartyService';
import { MusicQueueService } from './services/MusicQueueService';
import { TranscodeService } from './services/TranscodeService';
import { PlaystateService } from './services/PlaystateService';
import { SocketGateway } from './realtime/SocketGateway';
import type { ClientToServerEvents, EmbySession, MediaType, QueueItem, ServerToClientEvents } from './types';
@ -286,6 +287,7 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
queue,
mediaType
);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
if (mediaType === 'music') {
MusicQueueService.init(party, {
@ -336,6 +338,7 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
first.Id, first.Name, `/api/catalog/image/${first.Id}`,
null, mediaType
);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
// Same mechanism POST /api/parties/:id/tracks already uses (see
// below) - init seeds+auto-starts the first track, addTrack queues
// the rest, exactly as if they'd been added one at a time.
@ -369,6 +372,7 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
queue[0]!.itemId, queue[0]!.itemName, queue[0]!.itemImageUrl,
queue, mediaType
);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
SocketGateway.broadcastLobby(io);
res.json(PartyService.toPublicState(party, session.userId));
}
@ -409,6 +413,7 @@ app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
}
const session = req.embySession!;
PartyService.join(party, session.userId, session.userName);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
SocketGateway.broadcastLobby(io); // member count changed - reflect it on the home page immediately
res.json(PartyService.toPublicState(party, session.userId));
});
@ -469,6 +474,23 @@ setInterval(() => PartyService.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 *
// normal path is PartyService.leave's destroy branch ending it immediately.
setInterval(() => TranscodeService.reapIdleSessions(30 * 60 * 1000), 5 * 60 * 1000);
// Pushes every connected member's own progress to Emby, under their own
// account - see docs/architecture.md's "Sync watch-party progress back
// to Emby" section for the full design and its deliberate v1 scope
// (periodic Progress reports only, no explicit start/stop). Best-effort:
// PlaystateService.reportProgress swallows its own failures, so one bad
// token or a transient Emby hiccup never stops the rest of this sweep.
setInterval(() => {
for (const party of PartyService.listActive()) {
const state = PartyService.getCurrentPlaybackState(party);
if (!state) continue; // nothing currently playing (e.g. an empty music queue)
for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue;
PlaystateService.reportProgress(member.embyToken, member.userId, state.itemId, state.positionTicks, state.isPaused);
}
}
}, 20 * 1000);
server.listen(PORT, () => {
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
});

View file

@ -72,7 +72,7 @@ import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } fr
* colliding with its internal error handling.)
*/
class SocketGatewayClass {
attach(io: AppServer, parseCookies: (header: string | undefined) => Record<string, string>, getSession: (id: string) => { userId: string; userName: string } | null): void {
attach(io: AppServer, parseCookies: (header: string | undefined) => Record<string, string>, getSession: (id: string) => { userId: string; userName: string; embyToken: string } | null): void {
io.on('connection', (socket) => {
const cookies = parseCookies(socket.request.headers.cookie);
const session = cookies.wp_session ? getSession(cookies.wp_session) : null;
@ -125,6 +125,11 @@ class SocketGatewayClass {
party = p;
member.connection = socket;
member.deviceId = deviceId;
// Re-confirmed on every successful join, not just set once - the
// periodic Emby-playstate sweep (index.ts) reads this to report
// this member's own progress under their own Emby account (see
// PlaystateService).
member.embyToken = session.embyToken;
socket.join(party.id);
this.broadcastMembers(io, party);
}));

View file

@ -98,6 +98,45 @@ class PartyServiceClass {
return member;
}
/**
* Records `userId`'s own Emby access token on their member entry -
* called right after every REST create/join call site (index.ts) and
* re-confirmed on every socket "join" (SocketGateway), so the periodic
* Emby-playstate sweep (index.ts/PlaystateService) can always report
* *their own* progress under *their own* Emby account. A no-op if
* they're not (or no longer) a member - defensive only, every real
* call site has just created/found them.
*/
setMemberEmbyToken(party: Party, userId: string, embyToken: string): void {
const member = party.members.get(userId);
if (member) member.embyToken = embyToken;
}
/**
* What's currently playing for this party, for the Emby-playstate
* sweep (index.ts) - null if there's genuinely nothing to report
* (an empty music queue, or - shouldn't happen - no resolvable host).
* Video reads the host's own tracked position/pause state (there's no
* per-*follower* tracking today - see docs/architecture.md's own
* "Approximation" note on why every member's report uses this same
* value); music reads the one shared, server-authoritative position
* every member already sees the same way (MusicQueueService).
*/
getCurrentPlaybackState(party: Party): { itemId: string; positionTicks: number; isPaused: boolean } | null {
if (party.mediaType === 'music') {
const current = party.music?.current;
if (!current) return null;
return {
itemId: current.track.itemId,
positionTicks: MusicQueueService.getElapsedSeconds(party) * 10000000,
isPaused: current.isPaused
};
}
const host = party.members.get(party.hostUserId);
if (!host) return null;
return { itemId: party.itemId, positionTicks: host.positionTicks, isPaused: host.isPaused };
}
/**
* Removes a member. If they were the host, promotes whoever's left
* (arbitrary but deterministic-ish - good enough for v1); if nobody's
@ -235,6 +274,7 @@ class PartyServiceClass {
userName,
connection: null,
deviceId: null,
embyToken: null,
isPaused: true,
positionTicks: 0,
lastUpdatedUtc: Date.now()

View file

@ -0,0 +1,44 @@
'use strict';
import { EmbyClient } from '../emby/EmbyClient';
import { Logger } from '../Logger';
/**
* Reports a party member's own playback progress back to Emby, under
* *their own* account - the one piece of this app that ever writes
* anything to Emby rather than just reading/proxying it (see
* docs/architecture.md's "Sync watch-party progress back to Emby"
* section for the full design and its deliberate v1 scope). Called from
* index.ts's periodic sweep, never from a request/response cycle - see
* that file for who actually decides *when* and *with what* to call
* this.
*
* Emby's own "PlaystateService" (the controller name in both Emby's and
* Jellyfin's server source) is what exposes this route.
* TODO(verify): unconfirmed against a real Emby server - there isn't
* one reachable from this dev environment, same standing caveat as this
* app's other two recent Emby-API assumptions (the ffmpeg direct-stream
* URL, the Playlists endpoint) - both turned out fine once actually
* tried.
*/
class PlaystateServiceClass {
/**
* Best-effort: any failure (Emby unreachable, a stale/expired token,
* a 4xx/5xx) is caught and logged here, never thrown - this runs on a
* timer with no request to fail, and one member's bad token or one
* transient Emby hiccup must never stop the rest of the sweep, let
* alone touch real playback.
*/
async reportProgress(embyToken: string, userId: string, itemId: string, positionTicks: number, isPaused: boolean): Promise<void> {
try {
await EmbyClient.fetch(`/Users/${userId}/PlayingItems/${itemId}/Progress`, embyToken, {
method: 'POST',
body: { PositionTicks: Math.round(positionTicks), IsPaused: isPaused }
});
} catch (err) {
Logger.playstate(`progress report failed userId=${userId} itemId=${itemId}: ${(err as Error).message}`);
}
}
}
export const PlaystateService = new PlaystateServiceClass();

View file

@ -200,6 +200,16 @@ export interface PartyMember {
* REST join/create routes has no live connection yet, so nothing to
* compare a new one against). See SocketGateway's "join" handler. */
deviceId: string | null;
/** This member's own Emby access token - set right after they
* create/join (REST) and re-confirmed on every socket "join" (see
* index.ts / SocketGateway). Needed so the periodic Emby-playstate
* sweep (index.ts) can report *their own* progress under *their own*
* Emby account, not just the host's - see PlaystateService and
* docs/architecture.md. Never sent to the client - see
* PartyService.toPublicState, which never includes it. Null until
* the first of those two things happens (mirrors deviceId's own
* null-until-known convention). */
embyToken: string | null;
isPaused: boolean;
positionTicks: number;
lastUpdatedUtc: number;

View file

@ -49,4 +49,34 @@ describe('EmbyClient.fetch', () => {
assert.equal(logged.filter((l) => l.includes('[watchparty:emby]')).length, 0);
});
it('JSON-encodes a body and sets method/Content-Type - PlaystateService\'s own POST calls need this', async (t) => {
let capturedInit: RequestInit | undefined;
t.mock.method(globalThis, 'fetch', async (_url: string, init: RequestInit) => {
capturedInit = init;
return new Response('{}', { status: 200 });
});
await EmbyClient.fetch('/Users/u1/PlayingItems/item1/Progress', 'tok', {
method: 'POST',
body: { PositionTicks: 123, IsPaused: false }
});
assert.equal(capturedInit?.method, 'POST');
assert.equal((capturedInit?.headers as Record<string, string>)['Content-Type'], 'application/json');
assert.equal(capturedInit?.body, JSON.stringify({ PositionTicks: 123, IsPaused: false }));
});
it('a plain GET (no body) never gets a Content-Type header - every existing call site relies on this staying untouched', async (t) => {
let capturedInit: RequestInit | undefined;
t.mock.method(globalThis, 'fetch', async (_url: string, init: RequestInit) => {
capturedInit = init;
return new Response('{}', { status: 200 });
});
await EmbyClient.fetch('/Users/u1/Views', 'tok');
assert.equal(capturedInit?.method, undefined);
assert.equal((capturedInit?.headers as Record<string, string>)['Content-Type'], undefined);
});
});

View file

@ -1,6 +1,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { PartyService } from '../src/services/PartyService';
import { MusicQueueService } from '../src/services/MusicQueueService';
describe('PartyService', () => {
it('create() sets mediaType and seeds the host as the first member', () => {
@ -113,4 +114,50 @@ describe('PartyService', () => {
assert.equal(state.burnInSubtitleIndex, '4');
assert.equal(state.burnInAudioIndex, '2');
});
it('setMemberEmbyToken records a member\'s own token, and is a harmless no-op for a non-member', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.setMemberEmbyToken(party, 'u-alice', 'tok-alice');
assert.equal(party.members.get('u-alice')?.embyToken, 'tok-alice');
assert.doesNotThrow(() => PartyService.setMemberEmbyToken(party, 'u-nobody', 'tok-x'));
});
it('getCurrentPlaybackState (video) reads the host\'s own tracked position, not a follower\'s', () => {
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
PartyService.join(party, 'u-bob', 'Bob');
const host = party.members.get('u-alice')!;
host.isPaused = false;
host.positionTicks = 5_000_000;
// A follower's own isPaused/positionTicks are never actually tracked
// today (see the method's own comment) - setting them here proves
// the result comes from the host, not whichever member happens to
// be iterated first.
const bob = party.members.get('u-bob')!;
bob.isPaused = true;
bob.positionTicks = 999;
const state = PartyService.getCurrentPlaybackState(party);
assert.deepEqual(state, { itemId: 'm1', positionTicks: 5_000_000, isPaused: false });
});
it('getCurrentPlaybackState (music) reads the shared queue position - the same for every member', () => {
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 60_000_000 }, 'u-alice', 'Alice');
const state = PartyService.getCurrentPlaybackState(party);
assert.equal(state?.itemId, 't1');
assert.equal(state?.isPaused, false);
assert.equal(typeof state?.positionTicks, 'number');
});
it('getCurrentPlaybackState is null when there\'s genuinely nothing playing (an empty music queue)', () => {
const party = PartyService.create('u-alice', 'Alice', 't1', 'Track One', '/img/t1', null, 'music');
MusicQueueService.init(party, null, 'u-alice', 'Alice');
assert.equal(PartyService.getCurrentPlaybackState(party), null);
});
});

View file

@ -0,0 +1,46 @@
// PlaystateService.reportProgress - mocks EmbyClient.fetch (not the
// global fetch it wraps), same isolation style as catalogService.test.ts,
// since this is about what PlaystateService itself sends, not
// EmbyClient's own plumbing (see embyClient.test.ts for that).
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { PlaystateService } from '../src/services/PlaystateService';
import { EmbyClient } from '../src/emby/EmbyClient';
describe('PlaystateService.reportProgress', () => {
it('POSTs to this user\'s own PlayingItems/Progress with their own token', async (t) => {
let capturedPath = '';
let capturedToken = '';
let capturedOptions: unknown;
t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: unknown) => {
capturedPath = path;
capturedToken = token;
capturedOptions = options;
return new Response('{}', { status: 200 });
});
await PlaystateService.reportProgress('tok-alice', 'u-alice', 'item1', 12_345_678, false);
assert.equal(capturedPath, '/Users/u-alice/PlayingItems/item1/Progress');
assert.equal(capturedToken, 'tok-alice');
assert.deepEqual(capturedOptions, { method: 'POST', body: { PositionTicks: 12345678, IsPaused: false } });
});
it('rounds a fractional positionTicks (Emby expects an integer)', async (t) => {
let capturedOptions: { body?: { PositionTicks?: number } } | undefined;
t.mock.method(EmbyClient, 'fetch', async (_path: string, _token: string, options: typeof capturedOptions) => {
capturedOptions = options;
return new Response('{}', { status: 200 });
});
await PlaystateService.reportProgress('tok', 'u1', 'item1', 100.7, true);
assert.equal(capturedOptions?.body?.PositionTicks, 101);
});
it('swallows a failure instead of throwing - this runs on a timer with nothing to catch it', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); });
await assert.doesNotReject(() => PlaystateService.reportProgress('tok', 'u1', 'item1', 0, false));
});
});

View file

@ -50,7 +50,7 @@ function attach(io: ReturnType<typeof fakeIo>) {
SocketGateway.attach(
io as unknown as AppServer,
(raw) => ({ wp_session: raw as string }),
(sessionId) => ({ userId: sessionId, userName: sessionId })
(sessionId) => ({ userId: sessionId, userName: sessionId, embyToken: `tok-${sessionId}` })
);
MusicQueueService.attachIo(io as unknown as AppServer, () => {});
}