diff --git a/README.md b/README.md index e9a8659..33b28fa 100644 --- a/README.md +++ b/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 diff --git a/docs/architecture.md b/docs/architecture.md index fcaec83..605d747 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/server/package.json b/server/package.json index 09d5d1d..9775c2c 100644 --- a/server/package.json +++ b/server/package.json @@ -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" diff --git a/server/src/Logger.ts b/server/src/Logger.ts index 10eb9a7..b88db7a 100644 --- a/server/src/Logger.ts +++ b/server/src/Logger.ts @@ -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}`); + } } diff --git a/server/src/emby/EmbyClient.ts b/server/src/emby/EmbyClient.ts index 3331017..2dc7a46 100644 --- a/server/src/emby/EmbyClient.ts +++ b/server/src/emby/EmbyClient.ts @@ -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 { + static async fetch(pathAndQuery: string, token: string, options: { range?: string; method?: string; body?: unknown } = {}): Promise { const url = pathAndQuery.startsWith('http') ? pathAndQuery : `${EMBY_SERVER_URL}${pathAndQuery}`; const headers: Record = { '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; } diff --git a/server/src/index.ts b/server/src/index.ts index ff48d1e..13b5e9e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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'})`); }); diff --git a/server/src/realtime/SocketGateway.ts b/server/src/realtime/SocketGateway.ts index ed3bc2b..d825419 100644 --- a/server/src/realtime/SocketGateway.ts +++ b/server/src/realtime/SocketGateway.ts @@ -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, getSession: (id: string) => { userId: string; userName: string } | null): void { + attach(io: AppServer, parseCookies: (header: string | undefined) => Record, 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); })); diff --git a/server/src/services/PartyService.ts b/server/src/services/PartyService.ts index 952b5ee..fd7ea2a 100644 --- a/server/src/services/PartyService.ts +++ b/server/src/services/PartyService.ts @@ -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() diff --git a/server/src/services/PlaystateService.ts b/server/src/services/PlaystateService.ts new file mode 100644 index 0000000..ce6f2f9 --- /dev/null +++ b/server/src/services/PlaystateService.ts @@ -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 { + 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(); diff --git a/server/src/types.ts b/server/src/types.ts index a2a86d3..84e37cd 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -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; diff --git a/server/test/embyClient.test.ts b/server/test/embyClient.test.ts index 835f88a..37c1427 100644 --- a/server/test/embyClient.test.ts +++ b/server/test/embyClient.test.ts @@ -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)['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)['Content-Type'], undefined); + }); }); diff --git a/server/test/partyService.test.ts b/server/test/partyService.test.ts index 6d9d284..a582432 100644 --- a/server/test/partyService.test.ts +++ b/server/test/partyService.test.ts @@ -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); + }); }); diff --git a/server/test/playstateService.test.ts b/server/test/playstateService.test.ts new file mode 100644 index 0000000..48b70c5 --- /dev/null +++ b/server/test/playstateService.test.ts @@ -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)); + }); +}); diff --git a/server/test/socketGateway.test.ts b/server/test/socketGateway.test.ts index 0fabebd..b3bf1e9 100644 --- a/server/test/socketGateway.test.ts +++ b/server/test/socketGateway.test.ts @@ -50,7 +50,7 @@ function attach(io: ReturnType) { 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, () => {}); }