'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[] { 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(); /** 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[] { 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, 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, 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();