A watch party built from a whole series or video playlist that remembers the group's progress and each member's, so someone coming back or joining late sees the items they missed and can catch up (solo, as a catch-up watch party, or by marking items watched). - StaticPartyService + StaticPartyStore: first on-disk state (JSON file under DATA_DIR, atomic debounced writes, flushed on SIGTERM) - each session is an ordinary Party linked via party.staticLink; group position is saved by the 20s sweep and resumed on the next session - PartyService.completeCurrentItem centralizes "item done" (Emby Played + static progress); new host-only "itemEnded" socket event covers the last queued item, which was never marked Played before - missed = group-completed minus watched in-app minus Emby Played - /static/:id view, create buttons on series/playlist pages, home section - Docker volume for /data, spec/static-watch-party.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
234 lines
12 KiB
TypeScript
234 lines
12 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);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
StaticPartyStore.flushSync(); // no debounced save from a previous test may land mid-test
|
|
fs.rmSync(path.join(dataDir, 'static-parties.json'), { force: true });
|
|
fs.rmSync(path.join(dataDir, 'static-parties.json.corrupt'), { 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('round-trips every party through the JSON file, without runtime-only state', async () => {
|
|
const sp = StaticPartyService.create(alice, { type: 'playlist', sourceId: 'pl1' }, 'Soirées', '/img/pl1', ITEMS);
|
|
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
|
|
StaticPartyService.setLiveParty(sp, 'live-1');
|
|
await StaticPartyStore.writeNow();
|
|
|
|
const onDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'static-parties.json'), 'utf8'));
|
|
assert.equal(onDisk.version, 1);
|
|
assert.equal('livePartyId' in onDisk.parties[0], 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');
|
|
});
|
|
|
|
it('a corrupt file is moved aside and the service starts empty instead of crashing', () => {
|
|
fs.writeFileSync(path.join(dataDir, 'static-parties.json'), '{ not json', 'utf8');
|
|
StaticPartyService.init();
|
|
assert.equal(StaticPartyService.list().length, 0);
|
|
assert.ok(fs.existsSync(path.join(dataDir, 'static-parties.json.corrupt')));
|
|
});
|
|
|
|
it('a missing file 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);
|
|
});
|
|
});
|