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>
217 lines
10 KiB
TypeScript
217 lines
10 KiB
TypeScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { PartyService } from '../src/services/PartyService';
|
|
import { MusicQueueService } from '../src/services/MusicQueueService';
|
|
import { PlaystateService } from '../src/services/PlaystateService';
|
|
import type { AppSocket } from '../src/types';
|
|
|
|
describe('PartyService', () => {
|
|
it('create() sets mediaType and seeds the host as the first member', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01 - Pilote', '/img/ep1', [
|
|
{ itemId: 'ep1', itemName: 'S01E01 - Pilote', itemImageUrl: '/img/ep1' },
|
|
{ itemId: 'ep2', itemName: 'S01E02 - Suite', itemImageUrl: '/img/ep2' }
|
|
], 'video');
|
|
|
|
assert.equal(party.mediaType, 'video');
|
|
assert.equal(party.members.size, 1);
|
|
assert.equal(party.hostUserId, 'u-alice');
|
|
assert.equal(party.burnInSubtitleIndex, null, 'no burn-in stream until the host picks one');
|
|
assert.equal(party.burnInAudioIndex, null);
|
|
assert.equal(party.textSubtitleIndex, null, 'no text subtitle pick until the host picks one');
|
|
});
|
|
|
|
it('advanceQueue moves through the fixed queue and stops at the end', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
|
|
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
|
|
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
|
|
], 'video');
|
|
|
|
assert.equal(PartyService.advanceQueue(party), true);
|
|
assert.equal(party.itemId, 'ep2');
|
|
assert.equal(PartyService.advanceQueue(party), false, 'nothing left after the last item');
|
|
});
|
|
|
|
it('advanceQueue clears the party\'s burn-in/text-subtitle choice - a new item\'s stream indices mean nothing on the old one', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
|
|
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
|
|
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
|
|
], 'video');
|
|
party.burnInSubtitleIndex = '4';
|
|
party.burnInAudioIndex = '2';
|
|
party.textSubtitleIndex = '3';
|
|
|
|
PartyService.advanceQueue(party);
|
|
|
|
assert.equal(party.burnInSubtitleIndex, null);
|
|
assert.equal(party.burnInAudioIndex, null);
|
|
assert.equal(party.textSubtitleIndex, null);
|
|
});
|
|
|
|
it('advanceQueue marks the outgoing item Played for every connected member with a token, under their own account - see PlaystateService', (t) => {
|
|
const calls: [string, string, string][] = [];
|
|
t.mock.method(PlaystateService, 'markPlayed', async (embyToken: string, userId: string, itemId: string) => {
|
|
calls.push([embyToken, userId, itemId]);
|
|
});
|
|
|
|
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
|
|
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
|
|
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
|
|
], 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
PartyService.setMemberEmbyToken(party, 'u-alice', 'tok-alice');
|
|
PartyService.setMemberEmbyToken(party, 'u-bob', 'tok-bob');
|
|
// A real socket connection isn't needed for anything else this test
|
|
// checks - just enough to pass advanceQueue's own "connected" guard,
|
|
// same one the periodic sweep in index.ts already uses.
|
|
party.members.get('u-alice')!.connection = {} as unknown as AppSocket;
|
|
party.members.get('u-bob')!.connection = {} as unknown as AppSocket;
|
|
|
|
PartyService.advanceQueue(party);
|
|
|
|
assert.deepEqual(calls.sort(), [['tok-alice', 'u-alice', 'ep1'], ['tok-bob', 'u-bob', 'ep1']].sort(), 'marks the OUTGOING item (ep1), not the new one just moved to');
|
|
});
|
|
|
|
it('advanceQueue never marks a member Played if they have no connection or no token yet', (t) => {
|
|
let called = false;
|
|
t.mock.method(PlaystateService, 'markPlayed', async () => { called = true; });
|
|
|
|
const party = PartyService.create('u-alice', 'Alice', 'ep1', 'S01E01', '/img/ep1', [
|
|
{ itemId: 'ep1', itemName: 'S01E01', itemImageUrl: '/img/ep1' },
|
|
{ itemId: 'ep2', itemName: 'S01E02', itemImageUrl: '/img/ep2' }
|
|
], 'video');
|
|
// Host never got a connection/token set at all in this test - same as
|
|
// a REST-created party nobody has actually joined a socket to yet.
|
|
|
|
PartyService.advanceQueue(party);
|
|
|
|
assert.equal(called, false);
|
|
});
|
|
|
|
it('leave(): a non-host leaving neither destroys the party nor hands off', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
|
|
const result = PartyService.leave(party, 'u-bob');
|
|
assert.equal(result.destroyed, false);
|
|
assert.equal(result.hostChanged, false);
|
|
});
|
|
|
|
it('leave(): the host leaving with someone left hands off, and party.hostUserId actually updates', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
PartyService.join(party, 'u-bob', 'Bob');
|
|
|
|
const result = PartyService.leave(party, 'u-alice');
|
|
assert.equal(result.hostChanged, true);
|
|
assert.equal(result.newHostUserId, 'u-bob');
|
|
assert.equal(party.hostUserId, 'u-bob');
|
|
});
|
|
|
|
it('leave(): the last member leaving destroys the party', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
|
|
const result = PartyService.leave(party, 'u-alice');
|
|
assert.equal(result.destroyed, true);
|
|
assert.equal(PartyService.getById(party.id), null);
|
|
});
|
|
|
|
it('join() is idempotent: re-joining an existing member returns it as-is instead of resetting it', () => {
|
|
const party = PartyService.create('u-alice', 'Alice', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
const bob = PartyService.join(party, 'u-bob', 'Bob');
|
|
bob.isPaused = false;
|
|
bob.positionTicks = 12345;
|
|
bob.deviceId = 'device-1';
|
|
|
|
// The REST "Rejoindre" route (and a second device/tab's socket "join")
|
|
// call this every time regardless of whether the caller is already a
|
|
// member - this must not reset any of the live state just set above
|
|
// (see this method's own comment for the crash that used to cause).
|
|
const bobAgain = PartyService.join(party, 'u-bob', 'Bob');
|
|
assert.equal(bobAgain, bob, 'must be the exact same member object, not a fresh one');
|
|
assert.equal(bobAgain.isPaused, false, 'a re-join must not reset live state');
|
|
assert.equal(bobAgain.positionTicks, 12345);
|
|
assert.equal(bobAgain.deviceId, 'device-1');
|
|
assert.equal(party.members.size, 2, 'still exactly one entry for bob, not a duplicate');
|
|
});
|
|
|
|
it('removeIdleParties sweeps a party whose lastActivityUtc is past the cutoff', () => {
|
|
const party = PartyService.create('u-dave', 'Dave', 'm1', 'Un Film', '/img/m1', null, 'video');
|
|
party.lastActivityUtc = Date.now() - 5 * 60 * 60 * 1000;
|
|
|
|
PartyService.removeIdleParties(4 * 60 * 60 * 1000);
|
|
assert.equal(PartyService.getById(party.id), null);
|
|
});
|
|
|
|
it('toPublicState reports youAreHost for the creator, and music:null for a video party', () => {
|
|
const party = PartyService.create('u-eve', 'Eve', 'm2', 'Un Autre Film', '/img/m2', null, 'video');
|
|
const state = PartyService.toPublicState(party, 'u-eve');
|
|
|
|
assert.equal(state.youAreHost, true);
|
|
assert.equal(state.music, null);
|
|
});
|
|
|
|
it('toPublicState exposes the party\'s current burn-in choice - a late joiner\'s getParty must see it too', () => {
|
|
const party = PartyService.create('u-eve', 'Eve', 'm2', 'Un Autre Film', '/img/m2', null, 'video');
|
|
party.burnInSubtitleIndex = '4';
|
|
party.burnInAudioIndex = '2';
|
|
|
|
const state = PartyService.toPublicState(party, 'u-eve');
|
|
|
|
assert.equal(state.burnInSubtitleIndex, '4');
|
|
assert.equal(state.burnInAudioIndex, '2');
|
|
});
|
|
|
|
it('toPublicState exposes the party\'s current text subtitle choice - a late joiner\'s getParty must see it too', () => {
|
|
const party = PartyService.create('u-eve', 'Eve', 'm2', 'Un Autre Film', '/img/m2', null, 'video');
|
|
party.textSubtitleIndex = '3';
|
|
|
|
const state = PartyService.toPublicState(party, 'u-eve');
|
|
|
|
assert.equal(state.textSubtitleIndex, '3');
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|