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>
This commit is contained in:
parent
b45a243e59
commit
11f003d269
14 changed files with 373 additions and 6 deletions
12
README.md
12
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'})`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
44
server/src/services/PlaystateService.ts
Normal file
44
server/src/services/PlaystateService.ts
Normal 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();
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
46
server/test/playstateService.test.ts
Normal file
46
server/test/playstateService.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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, () => {});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue