- 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>
319 lines
13 KiB
TypeScript
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();
|