watchparty/server/test/musicQueueService.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

175 lines
8.6 KiB
TypeScript

// Exercises MusicQueueService directly against fake parties - no Express/
// session/Emby involved. Covers the intricate bits: auto-start on first
// add, server-authoritative auto-advance via a real timer, pause/resume
// elapsed-time math, majority vote-skip, and - given how strongly memory
// leaks have mattered throughout this project (see docs/architecture.md) -
// that the advance timer is ALWAYS cleared (never left dangling) on skip,
// on destroy, and is never double-scheduled.
import { describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';
import { setTimeout as sleep } from 'node:timers/promises';
import { MusicQueueService } from '../src/services/MusicQueueService';
import { PlaystateService } from '../src/services/PlaystateService';
import type { AppSocket, Party, PartyMember, MusicState } from '../src/types';
function fakeParty(id: string): Party {
return {
id,
mediaType: 'music',
itemId: '',
itemName: '',
itemImageUrl: '',
queue: [],
queueIndex: 0,
hostUserId: 'u-alice',
createdUtc: Date.now(),
lastActivityUtc: Date.now(),
members: new Map(),
burnInSubtitleIndex: null,
burnInAudioIndex: null,
textSubtitleIndex: null,
streamAudioIndex: null,
streamQuality: null
};
}
/** A connected member with a known token - the two things
* playNext's own markPlayed loop (and index.ts's periodic sweep) both
* gate on. Not used by most tests in this file at all: `fakeParty`'s
* own `members` map starts empty, and MusicQueueService's methods never
* populate it themselves (that's PartyService.join's job) - so unless a
* test explicitly adds one via this, playNext's markPlayed loop simply
* has nothing to iterate, same as an empty party in production. */
function fakeMember(userId: string, userName: string, embyToken: string): PartyMember {
return {
userId,
userName,
connection: {} as unknown as AppSocket,
deviceId: null,
embyToken,
isPaused: false,
positionTicks: 0,
lastUpdatedUtc: Date.now()
};
}
function music(party: Party): MusicState {
return party.music as MusicState;
}
describe('MusicQueueService', () => {
it('init() auto-starts the first track and mirrors it onto party.itemName', () => {
const party = fakeParty('p1');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
assert.equal(music(party).current?.track.itemId, 't1');
assert.equal(music(party).queue.length, 0);
assert.equal(party.itemName, 'Track One');
});
it('adding a track while one plays queues it instead of jumping the gun', () => {
const party = fakeParty('p2');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob');
assert.equal(music(party).current?.track.itemId, 't1');
assert.equal(music(party).queue.length, 1);
});
it('voteSkip needs a strict majority of connected members, not any count', () => {
const party = fakeParty('p3');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob');
assert.equal(MusicQueueService.voteSkip(party, 'u-alice', 'Alice', 2), false, 'one of two needed should not skip yet');
assert.equal(music(party).current?.track.itemId, 't1');
assert.equal(MusicQueueService.voteSkip(party, 'u-bob', 'Bob', 2), true, 'the second vote should reach the majority');
assert.equal(music(party).current?.track.itemId, 't2');
assert.equal(music(party).skipVotes.size, 0, 'skipping clears the tally for the new track');
});
it('a skip marks the OUTGOING track Played for every connected member with a token, under their own account', (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 = fakeParty('p3b');
party.members.set('u-alice', fakeMember('u-alice', 'Alice', 'tok-alice'));
party.members.set('u-bob', fakeMember('u-bob', 'Bob', 'tok-bob'));
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob');
assert.equal(calls.length, 0, 'starting the very first track has nothing outgoing to mark');
// connectedMemberCount=2 needs a strict majority (2) to actually
// skip - same arithmetic as the "needs a strict majority" test above.
MusicQueueService.voteSkip(party, 'u-alice', 'Alice', 2);
assert.equal(calls.length, 0, 'one vote of two is not a majority yet - nothing outgoing to mark');
MusicQueueService.voteSkip(party, 'u-bob', 'Bob', 2);
assert.deepEqual(calls.sort(), [['tok-alice', 'u-alice', 't1'], ['tok-bob', 'u-bob', 't1']].sort(), 'marks t1 (outgoing), not t2 (just started)');
});
it('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 = fakeParty('p3c');
party.members.set('u-alice', { ...fakeMember('u-alice', 'Alice', 'tok-alice'), connection: null });
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 }, 'u-alice', 'Alice');
MusicQueueService.addTrack(party, { itemId: 't2', itemName: 'Track Two', itemImageUrl: '/img/t2', durationTicks: 30_000_000 }, 'u-bob', 'Bob');
MusicQueueService.voteSkip(party, 'u-alice', 'Alice', 1);
assert.equal(called, false);
});
it('pause freezes elapsed time; resume continues it from where it froze', async () => {
const party = fakeParty('p4');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 }, 'u-alice', 'Alice');
MusicQueueService.setPaused(party, true);
assert.equal(music(party).current?.isPaused, true);
const frozen = MusicQueueService.getElapsedSeconds(party);
await sleep(50);
assert.equal(MusicQueueService.getElapsedSeconds(party), frozen, 'time must not advance while paused');
MusicQueueService.setPaused(party, false);
assert.equal(music(party).current?.isPaused, false);
await sleep(50);
assert.ok(MusicQueueService.getElapsedSeconds(party) >= frozen, 'time should advance again after resuming');
MusicQueueService.destroy(party); // avoid leaking this test's own long-duration timer
});
it('a short track auto-advances on its own via a real timer, with no client reporting "ended"', async () => {
const party = fakeParty('p5');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 10_000_000 /* 1s */ }, 'u-alice', 'Alice');
assert.notEqual(music(party).advanceTimer, null, 'a timer should be scheduled for the auto-advance');
await sleep(1300); // a bit over the 1s duration
assert.equal(music(party).current, null, 'nothing queued behind it, so current should now be empty');
});
it('destroy() clears a pending timer - the actual leak-prevention path', (t) => {
const realClearTimeout = global.clearTimeout;
const cleared = mock.fn(realClearTimeout);
global.clearTimeout = cleared as typeof clearTimeout;
t.after(() => { global.clearTimeout = realClearTimeout; });
const party = fakeParty('p6');
MusicQueueService.init(party, { itemId: 't1', itemName: 'Track One', itemImageUrl: '/img/t1', durationTicks: 600_000_000 /* 60s - stays pending */ }, 'u-alice', 'Alice');
assert.notEqual(music(party).advanceTimer, null);
MusicQueueService.destroy(party);
assert.equal(cleared.mock.callCount(), 1, 'clearTimeout should have been called exactly once');
assert.equal(music(party).advanceTimer, null);
});
it('removeVote() is a harmless no-op for a party with no music state at all', () => {
const videoParty: Party = { ...fakeParty('p7'), mediaType: 'video' };
delete videoParty.music;
assert.doesNotThrow(() => MusicQueueService.removeVote(videoParty, 'u-anyone'));
});
});