watchparty/server/test/playstateService.test.ts
Nicolas b167e321e7
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 13s
Replace PlayingItems/Progress with direct UserData writes
Report: "still don't see my Emby profile updated - watched items
should be marked read, partially-watched ones should show in Resume".
After two rounds of fixing PlayingItems/Progress's request shape
(missing MediaSourceId, then missing PlaySessionId), this pointed at
something more fundamental: this app deliberately never calls the
matching PlayingItems "Start"/"Stop" - a documented v1 scope cut - and
a bare Progress report with no corresponding Start may simply never
reach the resume-position/watched-history data real clients read,
regardless of how correctly-shaped the request is.

Confirmed directly against the real server that this data lives
somewhere else entirely, trivially readable/writable with no playback
session required at all: every item Emby returns already carries the
requesting user's own UserData embedded (PlaybackPositionTicks/Played/
PlayCount/IsFavorite/...) - the exact thing Continue Watching and the
watched checkmark actually read - via a plain
POST /Users/{userId}/Items/{itemId}/UserData.

Switched entirely to that instead of layering a third fix onto
PlayingItems:
- PlaystateService.updatePosition (replacing reportProgress, same
  ~20s sweep in index.ts) updates just PlaybackPositionTicks.
- New PlaystateService.markPlayed sets Played: true and resets
  PlaybackPositionTicks to 0 - called once per connected member (with
  a token) when the party moves past an item for good:
  PartyService.advanceQueue (next episode) and
  MusicQueueService.playNext (a track naturally finishing, or a
  majority vote-skip - both go through this one method). Addresses
  the other half of the report ("watched items marked as watched").
- Both go through one private fetch-merge-write helper: GET the item
  first, apply only the field(s) being changed to a copy of its
  current UserData, POST the whole result back. Emby's spec documents
  the body as the complete UserItemDataDto with nothing indicating
  merge-not-replace semantics, and this is the one write in the whole
  app where guessing wrong risks actually erasing a user's own real
  data (a favorite, a rating) - so never a bare partial POST.

Reverted the MediaSourceId/PlaySessionId plumbing rounds 1-2 added
(CatalogService.negotiateStreamTarget back to returning a bare target
path, PartyStreamService no longer caches either) - nothing needs them
now that UserData doesn't require an active PlayingItems session.

Not yet confirmed end to end against a real server (no safe way to
fire a real, state-mutating test call without one).

Tests: PlaystateService's fetch-merge-write behavior in detail (a
favorite/rating/play-count survives a position-only update byte for
byte, a Played-only update doesn't disturb IsFavorite, a failed read
skips the write entirely, an item with no UserData yet starts from an
empty object), its request/outcome logging including that the token
never leaks into a log line, and that advanceQueue/playNext call
markPlayed for the outgoing item only, only for connected+tokened
members. catalogService/partyStreamService tests reverted to their
simpler pre-existing shape. Full typecheck/test/build green
(107/107 tests). docs/architecture.md and README.md updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 23:53:59 +02:00

158 lines
8.2 KiB
TypeScript

// PlaystateService.updatePosition/markPlayed - mocks EmbyClient.fetch
// (not the global fetch it wraps), same isolation style as
// catalogService.test.ts, since this is about what PlaystateService
// itself sends/merges, 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';
/** What a real `GET /Users/{userId}/Items/{itemId}` comes back with -
* confirmed directly against a real server that the requesting user's
* own UserData is always embedded right on the item like this. */
function itemResponse(userData: Record<string, unknown> | undefined): Response {
return new Response(JSON.stringify({ Id: 'item1', UserData: userData }), { status: 200 });
}
describe('PlaystateService.updatePosition', () => {
it('GETs the item, then POSTs UserData with only PlaybackPositionTicks changed - everything else survives untouched', async (t) => {
const capturedCalls: { path: string; token: string; options: unknown }[] = [];
t.mock.method(EmbyClient, 'fetch', async (path: string, token: string, options: unknown) => {
capturedCalls.push({ path, token, options });
if (capturedCalls.length === 1) {
return itemResponse({ PlaybackPositionTicks: 0, Played: false, IsFavorite: true, Rating: 9.5, PlayCount: 3 });
}
return new Response('{}', { status: 200 });
});
await PlaystateService.updatePosition('tok-alice', 'u-alice', 'item1', 12_345_678);
assert.equal(capturedCalls.length, 2);
assert.equal(capturedCalls[0]!.path, '/Users/u-alice/Items/item1');
assert.equal(capturedCalls[0]!.token, 'tok-alice');
assert.equal(capturedCalls[0]!.options, undefined, 'the GET is a plain fetch, no method/body');
assert.equal(capturedCalls[1]!.path, '/Users/u-alice/Items/item1/UserData');
assert.equal(capturedCalls[1]!.token, 'tok-alice');
const posted = capturedCalls[1]!.options as { method: string; body: Record<string, unknown> };
assert.equal(posted.method, 'POST');
// The whole point of fetch-merge-write: a favorite/rating/play count
// this app never asked to change must come back out exactly as read -
// guessing wrong here risks actually erasing real user data, not just
// silently no-op'ing the way the old Progress-endpoint bugs did.
assert.equal(posted.body.IsFavorite, true);
assert.equal(posted.body.Rating, 9.5);
assert.equal(posted.body.PlayCount, 3);
assert.equal(posted.body.Played, false, 'Played is preserved, not touched by a position-only update');
assert.equal(posted.body.PlaybackPositionTicks, 12345678, 'only this field actually changes');
});
it('rounds a fractional positionTicks (Emby expects an integer)', async (t) => {
let posted: Record<string, unknown> | undefined;
t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options?: { body?: Record<string, unknown> }) => {
if (path.endsWith('/UserData')) { posted = options?.body; return new Response('{}', { status: 200 }); }
return itemResponse({});
});
await PlaystateService.updatePosition('tok', 'u1', 'item1', 100.7);
assert.equal(posted?.PlaybackPositionTicks, 101);
});
it('has no existing UserData at all (a never-touched item) - still works, starting from an empty object', async (t) => {
let posted: Record<string, unknown> | undefined;
t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options?: { body?: Record<string, unknown> }) => {
if (path.endsWith('/UserData')) { posted = options?.body; return new Response('{}', { status: 200 }); }
return itemResponse(undefined);
});
await assert.doesNotReject(() => PlaystateService.updatePosition('tok', 'u1', 'item1', 5000));
assert.equal(posted?.PlaybackPositionTicks, 5000);
});
it('skips the write entirely if the GET fails - never posts with missing/corrupt data', async (t) => {
let postCalled = false;
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
if (path.endsWith('/UserData')) postCalled = true;
return new Response('nope', { status: 404 });
});
await assert.doesNotReject(() => PlaystateService.updatePosition('tok', 'u1', 'item1', 0));
assert.equal(postCalled, false, 'a failed read must not be followed by a write built on nothing');
});
it('swallows a thrown error instead of rejecting - 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.updatePosition('tok', 'u1', 'item1', 0));
});
});
describe('PlaystateService.markPlayed', () => {
it('POSTs Played: true and resets PlaybackPositionTicks to 0, preserving other fields', async (t) => {
let posted: Record<string, unknown> | undefined;
t.mock.method(EmbyClient, 'fetch', async (path: string, _token: string, options?: { body?: Record<string, unknown> }) => {
if (path.endsWith('/UserData')) { posted = options?.body; return new Response('{}', { status: 200 }); }
return itemResponse({ PlaybackPositionTicks: 3_000_000, Played: false, IsFavorite: true });
});
await PlaystateService.markPlayed('tok', 'u1', 'item1');
assert.equal(posted?.Played, true);
assert.equal(posted?.PlaybackPositionTicks, 0);
assert.equal(posted?.IsFavorite, true, 'a favorite must survive being marked played');
});
it('swallows a thrown error instead of rejecting - this runs from an item-advance path that must never stall on it', async (t) => {
t.mock.method(EmbyClient, 'fetch', async () => { throw new Error('Emby unreachable'); });
await assert.doesNotReject(() => PlaystateService.markPlayed('tok', 'u1', 'item1'));
});
});
describe('PlaystateService logging', () => {
it('logs both outgoing requests and the final outcome, never the token itself', async (t) => {
const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
if (path.endsWith('/UserData')) return new Response('{}', { status: 200 });
return itemResponse({});
});
await PlaystateService.updatePosition('super-secret-token', 'u-alice', 'item1', 5_000_000);
const lines = logged.filter((l) => l.includes('[watchparty:playstate]'));
assert.ok(lines.some((l) => l.includes('GET') && l.includes('/Users/u-alice/Items/item1')), 'the read must be logged');
assert.ok(lines.some((l) => l.includes('POST') && l.includes('/Users/u-alice/Items/item1/UserData')), 'the write must be logged');
assert.ok(lines.some((l) => l.includes('200') && l.includes('ok')), 'the outcome must be logged');
assert.ok(!lines.some((l) => l.includes('super-secret-token')), 'the token travels as a header, never logged');
});
it('logs a non-ok write as FAILED, with its real HTTP status', async (t) => {
const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(EmbyClient, 'fetch', async (path: string) => {
if (path.endsWith('/UserData')) return new Response('bad request', { status: 400 });
return itemResponse({});
});
await PlaystateService.updatePosition('tok', 'u1', 'item1', 0);
const outcomeLine = logged.find((l) => l.includes('[watchparty:playstate]') && l.includes('400'));
assert.ok(outcomeLine);
assert.ok(outcomeLine!.includes('FAILED'));
});
it('logs a failed read distinctly, without ever attempting/logging the write', async (t) => {
const logged: string[] = [];
t.mock.method(console, 'log', (line: string) => { logged.push(line); });
t.mock.method(EmbyClient, 'fetch', async () => new Response('not found', { status: 404 }));
await PlaystateService.updatePosition('tok', 'u1', 'item1', 0);
const lines = logged.filter((l) => l.includes('[watchparty:playstate]'));
assert.ok(lines.some((l) => l.includes('404') && l.includes('FAILED')));
assert.ok(!lines.some((l) => l.includes('POST')), 'no write should even be attempted, let alone logged, once the read failed');
});
});