watchparty/server/test/staticPartyService.test.ts
Nicolas 1e437e4a91
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Give static watch parties their own tab and one file per party
- new "Parties permanentes" tab (/static, sidebar + mobile sheet),
  highlighted on a party's own page too; the home page no longer lists
  static parties, and its live "Watch parties en cours" list leaves
  their sessions out
- StaticPartyStore now writes data/static-parties/<id>.json - one file
  per party, debounced per party, so a write only touches the party that
  changed and a corrupt file only costs that party (moved aside to
  .corrupt, the rest still load); leftover .tmp files are cleaned up;
  deleting a party removes its file and cancels any pending save
- the first release's single static-parties.json is migrated once on
  load, then kept as .migrated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:16:55 +02:00

295 lines
15 KiB
TypeScript

import { describe, it, before, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { StaticPartyService, computeMissedIds, mergeItemLists } from '../src/services/StaticPartyService';
import { StaticPartyStore } from '../src/services/StaticPartyStore';
import { PartyService } from '../src/services/PartyService';
import { PlaystateService } from '../src/services/PlaystateService';
import type { AppSocket, QueueItem } from '../src/types';
const item = (id: string): QueueItem => ({ itemId: id, itemName: `Episode ${id}`, itemImageUrl: `/img/${id}` });
const ITEMS = ['e1', 'e2', 'e3', 'e4'].map(item);
const alice = { userId: 'u-alice', userName: 'Alice' };
const bob = { userId: 'u-bob', userName: 'Bob' };
const NONE: ReadonlySet<string> = new Set();
let dataDir: string;
before(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-static-'));
StaticPartyStore.configure(dataDir);
});
const partiesDir = () => path.join(dataDir, 'static-parties');
beforeEach(() => {
StaticPartyStore.flushSync(); // no debounced save from a previous test may land mid-test
fs.rmSync(partiesDir(), { recursive: true, force: true });
for (const name of fs.readdirSync(dataDir)) fs.rmSync(path.join(dataDir, name), { recursive: true, force: true });
StaticPartyService.init();
});
describe('computeMissedIds', () => {
it('is every group-completed item, in the items\' own order', () => {
assert.deepEqual(computeMissedIds(ITEMS, ['e2', 'e1'], [], NONE), ['e1', 'e2']);
});
it('drops what the viewer watched in-app and what Emby already has as Played', () => {
assert.deepEqual(computeMissedIds(ITEMS, ['e1', 'e2', 'e3'], ['e1'], new Set(['e3'])), ['e2']);
});
it('never lists an item the group has not completed yet, even if the viewer watched ahead', () => {
assert.deepEqual(computeMissedIds(ITEMS, ['e1'], ['e4'], NONE), ['e1']);
});
});
describe('mergeItemLists', () => {
it('keeps the existing order, appends new items and keeps ones gone from the source', () => {
const { items, added } = mergeItemLists([item('e1'), item('e2')], [item('e2'), item('e3')]);
assert.deepEqual(items.map((i) => i.itemId), ['e1', 'e2', 'e3']);
assert.equal(added, 1);
});
it('refreshes a known item\'s name from the fresh fetch', () => {
const { items } = mergeItemLists([item('e1')], [{ ...item('e1'), itemName: 'Renamed' }]);
assert.equal(items[0]!.itemName, 'Renamed');
});
});
describe('StaticPartyService', () => {
it('create() makes the creator the first member, with nothing watched yet', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
assert.deepEqual(Object.keys(sp.members), ['u-alice']);
assert.equal(sp.group.nextIndex, 0);
assert.deepEqual(StaticPartyService.groupQueue(sp).map((i) => i.itemId), ['e1', 'e2', 'e3', 'e4']);
});
it('a group completion advances the group and credits only connected participants', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.join(sp, bob);
const link = { staticPartyId: sp.id, mode: 'group' as const };
StaticPartyService.onItemCompleted(link, 'e1', [alice, bob]);
StaticPartyService.onItemCompleted(link, 'e2', [alice]); // Bob missed this one
assert.equal(sp.group.nextIndex, 2);
assert.deepEqual(sp.group.completedItemIds, ['e1', 'e2']);
assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), ['e2']);
assert.deepEqual(StaticPartyService.missedIds(sp, alice.userId, NONE), []);
assert.deepEqual(StaticPartyService.groupQueue(sp).map((i) => i.itemId), ['e3', 'e4']);
});
it('a late joiner sees everything the group completed before them as missed', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
StaticPartyService.join(sp, bob);
assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), ['e1']);
});
it('a connected participant who never joined the static party becomes a member on completion', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice, bob]);
assert.deepEqual(sp.members[bob.userId]!.watchedItemIds, ['e1']);
});
it('a catch-up completion credits the participant without moving the group', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
StaticPartyService.join(sp, bob);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'catchUp' }, 'e1', [bob]);
assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), []);
assert.equal(sp.group.nextIndex, 1, 'unchanged by a catch-up');
assert.deepEqual(sp.group.completedItemIds, ['e1']);
});
it('markWatched removes an item from the missed list, and refuses an unknown item', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
assert.equal(StaticPartyService.markWatched(sp, bob, 'e1'), true);
assert.deepEqual(StaticPartyService.missedIds(sp, bob.userId, NONE), []);
assert.equal(StaticPartyService.markWatched(sp, bob, 'nope'), false);
});
it('recordPosition only keeps a position for the group\'s next item, and a completion resets it', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
const link = { staticPartyId: sp.id, mode: 'group' as const };
StaticPartyService.recordPosition(link, 'e3', 999);
assert.equal(sp.group.resumePositionTicks, 0, 'not the next item - ignored');
StaticPartyService.recordPosition(link, 'e1', 123);
assert.equal(sp.group.resumePositionTicks, 123);
StaticPartyService.recordPosition({ ...link, mode: 'catchUp' }, 'e1', 456);
assert.equal(sp.group.resumePositionTicks, 123, 'a catch-up session never moves the group');
StaticPartyService.onItemCompleted(link, 'e1', [alice]);
assert.equal(sp.group.resumePositionTicks, 0);
});
it('refreshItems appends new episodes without losing progress', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS.slice(0, 2));
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
StaticPartyService.refreshItems(sp, ITEMS);
assert.deepEqual(sp.items.map((i) => i.itemId), ['e1', 'e2', 'e3', 'e4']);
assert.equal(sp.group.nextIndex, 1);
assert.deepEqual(sp.group.completedItemIds, ['e1']);
});
it('only the creator can delete; leaving keeps the party', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.join(sp, bob);
assert.equal(StaticPartyService.delete(sp, bob.userId), false);
StaticPartyService.leave(sp, bob.userId);
assert.ok(StaticPartyService.getById(sp.id));
assert.equal(StaticPartyService.delete(sp, alice.userId), true);
assert.equal(StaticPartyService.getById(sp.id), null);
});
it('onPartyDestroyed only clears the matching live session', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.setLiveParty(sp, 'p1');
StaticPartyService.onPartyDestroyed({ staticPartyId: sp.id, mode: 'catchUp' }, 'p2');
assert.equal(sp.livePartyId, 'p1');
StaticPartyService.onPartyDestroyed({ staticPartyId: sp.id, mode: 'group' }, 'p1');
assert.equal(sp.livePartyId, null);
});
it('toDetail marks each item\'s group state and the viewer\'s own (in-app or Emby Played)', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
const detail = StaticPartyService.toDetail(sp, bob.userId, new Set(['e4']), null);
assert.deepEqual(detail.items.map((i) => i.groupState), ['watched', 'next', 'upcoming', 'upcoming']);
assert.deepEqual(detail.items.map((i) => i.viewerWatched), [false, false, false, true]);
assert.deepEqual(detail.missedItemIds, ['e1']);
assert.equal(detail.youAreMember, false);
assert.equal(detail.missedCount, 1);
});
});
describe('StaticPartyStore', () => {
it('writes one file per party, and round-trips it without runtime-only state', async () => {
const sp = StaticPartyService.create(alice, { type: 'playlist', sourceId: 'pl1' }, 'Soirées', '/img/pl1', ITEMS);
const other = StaticPartyService.create(bob, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
StaticPartyService.setLiveParty(sp, 'live-1');
await StaticPartyStore.writeNow();
assert.deepEqual(fs.readdirSync(partiesDir()).sort(), [`${sp.id}.json`, `${other.id}.json`].sort());
const onDisk = JSON.parse(fs.readFileSync(path.join(partiesDir(), `${sp.id}.json`), 'utf8'));
assert.equal(onDisk.version, 1);
assert.equal('livePartyId' in onDisk.party, false);
StaticPartyService.init();
const reloaded = StaticPartyService.getById(sp.id)!;
assert.equal(reloaded.name, 'Soirées');
assert.deepEqual(reloaded.group.completedItemIds, ['e1']);
assert.equal(reloaded.livePartyId, null, 'a restart ends every live session');
assert.ok(StaticPartyService.getById(other.id));
});
it('a change only rewrites the party that changed', async () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
const other = StaticPartyService.create(bob, { type: 'series', sourceId: 's1' }, 'B', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
const otherFile = path.join(partiesDir(), `${other.id}.json`);
const past = new Date(Date.now() - 60000);
fs.utimesSync(otherFile, past, past);
StaticPartyService.markWatched(sp, alice, 'e1');
await StaticPartyStore.writeNow();
assert.equal(fs.statSync(otherFile).mtimeMs, past.getTime(), 'the untouched party\'s file was not rewritten');
});
it('deleting a party removes its file, even with a save still pending', async () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
StaticPartyService.markWatched(sp, alice, 'e1'); // schedules a save...
StaticPartyService.delete(sp, alice.userId); // ...that must not recreate the file
await StaticPartyStore.writeNow();
assert.equal(fs.existsSync(path.join(partiesDir(), `${sp.id}.json`)), false);
});
it('a corrupt file is moved aside and only that party is lost', async () => {
const good = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'Good', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
fs.writeFileSync(path.join(partiesDir(), 'broken-id.json'), '{ not json', 'utf8');
StaticPartyService.init();
assert.deepEqual(StaticPartyService.list().map((p) => p.id), [good.id]);
assert.ok(fs.existsSync(path.join(partiesDir(), 'broken-id.json.corrupt')));
});
it('a leftover .tmp from an interrupted write is cleaned up, the real file kept', async () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
fs.writeFileSync(path.join(partiesDir(), `${sp.id}.json.tmp`), '{ half-writ', 'utf8');
StaticPartyService.init();
assert.ok(StaticPartyService.getById(sp.id));
assert.equal(fs.existsSync(path.join(partiesDir(), `${sp.id}.json.tmp`)), false);
});
it('migrates the first release\'s single static-parties.json into one file per party', () => {
const legacy = { version: 1, parties: [
{ id: 'aaa-1', name: 'One', source: { type: 'series', sourceId: 's1' }, imageUrl: '', createdByUserId: 'u-alice', createdUtc: 1,
items: ITEMS, group: { nextIndex: 1, resumePositionTicks: 0, completedItemIds: ['e1'], lastSessionUtc: null }, members: {} },
{ id: 'bbb-2', name: 'Two', source: { type: 'series', sourceId: 's1' }, imageUrl: '', createdByUserId: 'u-bob', createdUtc: 2,
items: ITEMS, group: { nextIndex: 0, resumePositionTicks: 0, completedItemIds: [], lastSessionUtc: null }, members: {} }
] };
fs.writeFileSync(path.join(dataDir, 'static-parties.json'), JSON.stringify(legacy), 'utf8');
StaticPartyService.init();
assert.deepEqual(StaticPartyService.list().map((p) => p.id).sort(), ['aaa-1', 'bbb-2']);
assert.deepEqual(StaticPartyService.getById('aaa-1')!.group.completedItemIds, ['e1']);
assert.ok(fs.existsSync(path.join(partiesDir(), 'aaa-1.json')));
assert.equal(fs.existsSync(path.join(dataDir, 'static-parties.json')), false);
assert.ok(fs.existsSync(path.join(dataDir, 'static-parties.json.migrated')), 'the original is kept, not deleted');
});
it('an empty data directory is a normal first start', () => {
StaticPartyService.init();
assert.equal(StaticPartyService.list().length, 0);
});
});
describe('PartyService <-> static watch party', () => {
const connected = { emit() {}, disconnect() {} } as unknown as AppSocket;
it('advanceQueue credits the outgoing item to a linked static party, once', (t) => {
t.mock.method(PlaystateService, 'markPlayed', async () => {});
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
const party = PartyService.create(alice.userId, alice.userName, 'e1', 'Episode e1', '/img/e1', ITEMS, 'video');
party.staticLink = { staticPartyId: sp.id, mode: 'group' };
party.members.get(alice.userId)!.connection = connected;
PartyService.completeCurrentItem(party); // "itemEnded"...
PartyService.advanceQueue(party); // ...then "next" on the same item: must not double-count
assert.deepEqual(sp.group.completedItemIds, ['e1']);
assert.deepEqual(sp.members[alice.userId]!.watchedItemIds, ['e1']);
assert.equal(sp.group.nextIndex, 1);
});
it('an ordinary party never touches any static party', (t) => {
t.mock.method(PlaystateService, 'markPlayed', async () => {});
const completed = t.mock.method(StaticPartyService, 'onItemCompleted');
const party = PartyService.create(alice.userId, alice.userName, 'e1', 'Episode e1', '/img/e1', ITEMS, 'video');
party.members.get(alice.userId)!.connection = connected;
PartyService.advanceQueue(party);
assert.equal(completed.mock.callCount(), 0);
});
it('the last member leaving a group session clears the static party\'s live session', () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
const party = PartyService.create(alice.userId, alice.userName, 'e1', 'Episode e1', '/img/e1', ITEMS, 'video');
party.staticLink = { staticPartyId: sp.id, mode: 'group' };
StaticPartyService.setLiveParty(sp, party.id);
PartyService.leave(party, alice.userId);
assert.equal(sp.livePartyId, null);
});
});