watchparty/server/src/services/StaticPartyService.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

319 lines
13 KiB
TypeScript

'use strict';
import crypto from 'crypto';
import { Logger } from '../Logger';
import { StaticPartyStore } from './StaticPartyStore';
import type {
PersistedStaticParty,
QueueItem,
StaticParty,
StaticPartyDetailDto,
StaticPartyLink,
StaticPartyMember,
StaticPartySummaryDto
} from '../types';
/** A connected participant, as reported by a live session. */
export interface Participant {
userId: string;
userName: string;
}
/**
* Which of `items` the viewer has missed, in the items' own order: every
* item the group already completed, minus what the viewer watched in-app
* (`watchedItemIds`) and minus what Emby itself already has as Played for
* them (`embyPlayedIds` - watched outside this app, or before this party
* existed). Pure - the one rule this whole feature is about, tested on
* its own (see staticPartyService.test.ts).
*/
export function computeMissedIds(
items: QueueItem[],
completedItemIds: string[],
watchedItemIds: string[],
embyPlayedIds: ReadonlySet<string>
): string[] {
const completed = new Set(completedItemIds);
const watched = new Set(watchedItemIds);
return items
.map((i) => i.itemId)
.filter((id) => completed.has(id) && !watched.has(id) && !embyPlayedIds.has(id));
}
/**
* Merges a freshly fetched item list into a static party's existing one:
* keeps the existing order and every existing item (even one since
* removed from the source - the group's history shouldn't lose it),
* refreshes names/images, and appends anything new (a new episode, a
* playlist addition) at the end in the source's own order. Returns the
* merged list and how many items were added. Pure.
*/
export function mergeItemLists(existing: QueueItem[], fresh: QueueItem[]): { items: QueueItem[]; added: number } {
const freshById = new Map(fresh.map((i) => [i.itemId, i]));
const known = new Set(existing.map((i) => i.itemId));
const items = existing.map((i) => freshById.get(i.itemId) ?? i);
const additions = fresh.filter((i) => !known.has(i.itemId));
return { items: items.concat(additions), added: additions.length };
}
/**
* Static (persistent) watch parties - a whole series or video playlist a
* group watches together over several sessions. Remembers the group's own
* progress (which items it completed, where it stopped) and each member's
* own (which items they watched), so someone coming back or joining late
* sees exactly what they missed. See spec/static-watch-party.md.
*
* A static party never plays anything itself: each session is an
* ordinary live Party (PartyService), created by index.ts and linked back
* here through `party.staticLink` - so the player, sync, shared stream,
* lobby and host handoff are all reused as-is. This service never imports
* PartyService (PartyService calls into this one), keeping the dependency
* one-way.
*
* Persisted through StaticPartyStore after every change - one file per
* party, so only the party that changed is ever rewritten.
*/
class StaticPartyServiceClass {
/** Every static party, by id. */
private partiesById = new Map<string, StaticParty>();
/** Loads every persisted party - once, at startup. */
public init(): void {
this.partiesById.clear();
for (const persisted of StaticPartyStore.load()) {
this.partiesById.set(persisted.id, { ...persisted, livePartyId: null });
}
}
/** Every static party, most recently active first. */
public list(): StaticParty[] {
return Array.from(this.partiesById.values()).sort((a, b) => this.lastActivity(b) - this.lastActivity(a));
}
/** One static party, or null. */
public getById(id: string): StaticParty | null {
return this.partiesById.get(id) || null;
}
/** Creates a static party over `items` - the creator is its first member. */
public create(
creator: Participant,
source: StaticParty['source'],
name: string,
imageUrl: string,
items: QueueItem[]
): StaticParty {
const now = Date.now();
const sp: StaticParty = {
id: crypto.randomUUID(),
name,
source,
imageUrl,
createdByUserId: creator.userId,
createdUtc: now,
items,
group: { nextIndex: 0, resumePositionTicks: 0, completedItemIds: [], lastSessionUtc: null },
members: {},
livePartyId: null
};
sp.members[creator.userId] = this.newMember(creator);
this.partiesById.set(sp.id, sp);
Logger.staticParty(`created id=${sp.id} name="${name}" source=${source.type}:${source.sourceId} items=${items.length} by=${Logger.who(creator.userId, creator.userName)}`);
this.persist(sp);
return sp;
}
/** Adds `who` as a member - idempotent (refreshes their name only). */
public join(sp: StaticParty, who: Participant): StaticPartyMember {
const existing = sp.members[who.userId];
if (existing) {
existing.userName = who.userName;
existing.lastSeenUtc = Date.now();
this.persist(sp);
return existing;
}
const member = this.newMember(who);
sp.members[who.userId] = member;
Logger.staticParty(`joined id=${sp.id} user=${Logger.who(who.userId, who.userName)} members=${Object.keys(sp.members).length}`);
this.persist(sp);
return member;
}
/** Removes a member - and their progress. The party itself stays, even
* empty: only its creator deletes it (see delete()). */
public leave(sp: StaticParty, userId: string): void {
const member = sp.members[userId];
if (!member) return;
delete sp.members[userId];
Logger.staticParty(`left id=${sp.id} user=${Logger.who(userId, member.userName)} members=${Object.keys(sp.members).length}`);
this.persist(sp);
}
/** Deletes the party for good - creator only. Returns false (and does
* nothing) for anyone else. */
public delete(sp: StaticParty, userId: string): boolean {
if (sp.createdByUserId !== userId) {
Logger.staticParty(`delete refused id=${sp.id} user=${userId} (not the creator)`);
return false;
}
this.partiesById.delete(sp.id);
Logger.staticParty(`deleted id=${sp.id} name="${sp.name}"`);
StaticPartyStore.remove(sp.id);
return true;
}
/** Merges a fresh fetch of the source into `sp.items` (see
* mergeItemLists) - new episodes/playlist entries appear at the end. */
public refreshItems(sp: StaticParty, fresh: QueueItem[]): void {
if (!fresh.length) return;
const { items, added } = mergeItemLists(sp.items, fresh);
const changed = added > 0 || items.some((item, i) => item !== sp.items[i]);
sp.items = items;
if (added) Logger.staticParty(`refreshed id=${sp.id} +${added} new item(s), total=${items.length}`);
if (changed) this.persist(sp);
}
/** What the group's next session plays: everything from the group's
* next item on. Empty once the group has seen everything. */
public groupQueue(sp: StaticParty): QueueItem[] {
return sp.items.slice(sp.group.nextIndex);
}
/** Records which live party is the group's current session (or none). */
public setLiveParty(sp: StaticParty, partyId: string | null): void {
sp.livePartyId = partyId;
if (partyId) {
sp.group.lastSessionUtc = Date.now();
Logger.staticParty(`session started id=${sp.id} partyId=${partyId} at item ${sp.group.nextIndex + 1}/${sp.items.length}`);
this.persist(sp);
}
}
/**
* A linked live session finished `itemId` (played to its end, or the
* host moved past it - the same heuristic as PlaystateService.markPlayed).
* Every connected participant gets it as watched (and becomes a member
* if they joined the live session without joining the static party
* first). In `group` mode the group itself also moves past it; in
* `catchUp` mode only those participants' own progress changes.
*/
public onItemCompleted(link: StaticPartyLink, itemId: string, connected: Participant[]): void {
const sp = this.partiesById.get(link.staticPartyId);
if (!sp) return; // deleted while a session was still running
for (const who of connected) {
const member = sp.members[who.userId] ?? (sp.members[who.userId] = this.newMember(who));
member.lastSeenUtc = Date.now();
if (!member.watchedItemIds.includes(itemId)) member.watchedItemIds.push(itemId);
}
if (link.mode === 'group') {
if (!sp.group.completedItemIds.includes(itemId)) sp.group.completedItemIds.push(itemId);
const index = sp.items.findIndex((i) => i.itemId === itemId);
if (index >= 0 && index + 1 > sp.group.nextIndex) sp.group.nextIndex = index + 1;
sp.group.resumePositionTicks = 0;
}
Logger.staticParty(`item completed id=${sp.id} mode=${link.mode} item=${itemId} by=[${connected.map((c) => c.userName).join(', ')}] group=${sp.group.completedItemIds.length}/${sp.items.length}`);
this.persist(sp);
}
/** The group session's current position - saved so the next session
* resumes there. Ignored for anything but the group's next item. */
public recordPosition(link: StaticPartyLink, itemId: string, positionTicks: number): void {
if (link.mode !== 'group') return;
const sp = this.partiesById.get(link.staticPartyId);
if (!sp || sp.items[sp.group.nextIndex]?.itemId !== itemId) return;
if (sp.group.resumePositionTicks === positionTicks) return;
sp.group.resumePositionTicks = positionTicks;
this.persist(sp);
}
/** A linked live party ended - forget it as the group's live session. */
public onPartyDestroyed(link: StaticPartyLink, partyId: string): void {
const sp = this.partiesById.get(link.staticPartyId);
if (!sp || sp.livePartyId !== partyId) return;
sp.livePartyId = null;
Logger.staticParty(`session ended id=${sp.id} partyId=${partyId} resumeAt=item ${sp.group.nextIndex + 1} ${Math.round(sp.group.resumePositionTicks / 10000000)}s`);
this.persist(sp);
}
/** "Marquer comme vu" - `who` becomes a member if they weren't yet. */
public markWatched(sp: StaticParty, who: Participant, itemId: string): boolean {
if (!sp.items.some((i) => i.itemId === itemId)) return false;
const member = sp.members[who.userId] ?? (sp.members[who.userId] = this.newMember(who));
if (!member.watchedItemIds.includes(itemId)) member.watchedItemIds.push(itemId);
member.lastSeenUtc = Date.now();
Logger.staticParty(`marked watched id=${sp.id} user=${Logger.who(who.userId, who.userName)} item=${itemId}`);
this.persist(sp);
return true;
}
/** `viewerUserId`'s missed items - see computeMissedIds. */
public missedIds(sp: StaticParty, viewerUserId: string, embyPlayedIds: ReadonlySet<string>): string[] {
const watched = sp.members[viewerUserId]?.watchedItemIds ?? [];
return computeMissedIds(sp.items, sp.group.completedItemIds, watched, embyPlayedIds);
}
/** The list-view shape of `sp` for `viewerUserId`. `livePartyId` is
* passed in already checked against PartyService (still alive). */
public toSummary(sp: StaticParty, viewerUserId: string, embyPlayedIds: ReadonlySet<string>, livePartyId: string | null): StaticPartySummaryDto {
return {
id: sp.id,
name: sp.name,
sourceType: sp.source.type,
imageUrl: sp.imageUrl,
itemCount: sp.items.length,
groupWatchedCount: sp.group.completedItemIds.length,
nextItemName: sp.items[sp.group.nextIndex]?.itemName ?? null,
memberCount: Object.keys(sp.members).length,
youAreMember: !!sp.members[viewerUserId],
youAreCreator: sp.createdByUserId === viewerUserId,
missedCount: this.missedIds(sp, viewerUserId, embyPlayedIds).length,
livePartyId
};
}
/** The detail-view shape of `sp` for `viewerUserId`. */
public toDetail(sp: StaticParty, viewerUserId: string, embyPlayedIds: ReadonlySet<string>, livePartyId: string | null): StaticPartyDetailDto {
const completed = new Set(sp.group.completedItemIds);
const viewerWatched = new Set(sp.members[viewerUserId]?.watchedItemIds ?? []);
return {
...this.toSummary(sp, viewerUserId, embyPlayedIds, livePartyId),
items: sp.items.map((item, index) => ({
...item,
groupState: completed.has(item.itemId) ? 'watched' : index === sp.group.nextIndex ? 'next' : 'upcoming',
viewerWatched: viewerWatched.has(item.itemId) || embyPlayedIds.has(item.itemId)
})),
missedItemIds: this.missedIds(sp, viewerUserId, embyPlayedIds),
members: Object.values(sp.members).map((m) => ({
userId: m.userId,
userName: m.userName,
watchedCount: m.watchedItemIds.length,
isCreator: m.userId === sp.createdByUserId
}))
};
}
/** Writes `sp`'s own file (debounced - see StaticPartyStore). Only the
* party that changed is ever rewritten. */
private persist(sp: StaticParty): void {
StaticPartyStore.save(sp.id, () => this.snapshotOf(sp));
}
/** `sp` minus runtime-only state. */
private snapshotOf(sp: StaticParty): PersistedStaticParty {
const { livePartyId: _live, ...persisted } = sp;
return persisted;
}
/** When `sp` last saw anything happen - for list ordering. */
private lastActivity(sp: StaticParty): number {
return Math.max(sp.createdUtc, sp.group.lastSessionUtc ?? 0);
}
/** A fresh member record for `who`. */
private newMember(who: Participant): StaticPartyMember {
const now = Date.now();
return { userId: who.userId, userName: who.userName, joinedUtc: now, watchedItemIds: [], lastSeenUtc: now };
}
}
export const StaticPartyService = new StaticPartyServiceClass();