Compare commits

..

No commits in common. "2de375ac62960135b51437377238656c2dd414da" and "04a9116f68bcc3b619894bd757f686d66d3e8917" have entirely different histories.

34 changed files with 36 additions and 1974 deletions

3
.gitignore vendored
View file

@ -21,9 +21,6 @@ server/dist/
client/dist/
*.tsbuildinfo
## Runtime data (static watch parties - see server/src/services/StaticPartyStore.ts)
data/
## OS cruft
.DS_Store
Thumbs.db

View file

@ -159,7 +159,7 @@ server/ Node.js + Express + Socket.IO backend: proxies ALL Emby
tsc to server/dist.
src/index.ts Express app + route wiring only - thin,
delegates to the services below
src/config.ts env-derived config (EMBY_SERVER_URL, PORT, DATA_DIR)
src/config.ts env-derived config (EMBY_SERVER_URL, PORT)
src/types.ts shared interfaces (Party, ItemDto, the
Socket.IO event maps, ...)
src/Logger.ts party/stream lifecycle logging
@ -245,14 +245,9 @@ build stage, so nothing needs building beforehand:
```bash
docker build -f server/Dockerfile -t watchparty .
docker run -d --name watchparty -p 3000:3000 -v "$(pwd)/data:/data" -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty
docker run -d --name watchparty -p 3000:3000 -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty
```
The `/data` volume holds the static (persistent) watch parties
(`DATA_DIR`, see [`spec/static-watch-party.md`](spec/static-watch-party.md)).
Without it they're lost whenever the container is recreated.
`docker-compose.yml` already mounts it.
Then put it behind your existing reverse proxy like everything else
(`theater.media.kyuno.fr`, `git.dev.kyuno.fr`, ...), e.g.
`watchparty.kyuno.fr` → `http://<container>:3000`.

View file

@ -150,9 +150,6 @@ export interface PartyConnection {
sendPlaystate(isPaused: boolean, positionTicks: number): void;
/** Host-only: advance the party's queue (e.g. to the next episode). */
next(): void;
/** Host-only: the last queued item played to its end - lets the server
* count it as watched (there's no "next" to send for it). */
itemEnded(): void;
/** Asks the host (and tells everyone else) "please pause". Video only -
* a music party has no host to ask, see voteSkip/setMusicPaused. */
requestPause(): void;
@ -233,9 +230,6 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
next() {
socket.emit('next');
},
itemEnded() {
socket.emit('itemEnded');
},
requestPause() {
socket.emit('requestPause');
},

View file

@ -1,74 +0,0 @@
// Static (persistent) watch parties - REST only, same origin, session
// cookie auth (see embyApi.ts). A static party's live sessions are
// ordinary parties: once one of these calls returns a partyId, everything
// else goes through api/party.ts and the normal player, unchanged. See
// server/src/services/StaticPartyService.ts and spec/static-watch-party.md.
import type { StaticPartyDetailDto, StaticPartySummaryDto } from '../types';
/** Same contract as api/party.ts's own helper: JSON in/out, the server's
* `error` message thrown as-is (they're written to be shown to users). */
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
let res: Response;
try {
res = await fetch(path, {
credentials: 'same-origin',
...options,
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
});
} catch (err) {
console.error('[staticParty] network error', path, err);
throw new Error('Serveur injoignable.');
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.warn('[staticParty] request failed', path, res.status, body.error);
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json() as Promise<T>;
}
/** Every static watch party, with the viewer's own missed count. */
export async function listStaticParties(): Promise<StaticPartySummaryDto[]> {
const data = await apiFetch<{ staticParties: StaticPartySummaryDto[] }>('/api/static-parties');
return data.staticParties;
}
/** Creates one from a series or a video playlist (`sourceId`). */
export function createStaticParty(sourceId: string, name?: string): Promise<StaticPartyDetailDto> {
return apiFetch('/api/static-parties', { method: 'POST', body: JSON.stringify({ sourceId, name }) });
}
/** Everything about one static watch party, from the viewer's side. */
export function getStaticParty(id: string): Promise<StaticPartyDetailDto> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}`);
}
/** Become a member. */
export function joinStaticParty(id: string): Promise<{ ok: true }> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/join`, { method: 'POST' });
}
/** Stop being a member (drops the viewer's own progress). */
export function leaveStaticParty(id: string): Promise<{ ok: true }> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/leave`, { method: 'POST' });
}
/** Delete it for everyone - creator only. */
export function deleteStaticParty(id: string): Promise<{ ok: true }> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
/** "Marquer comme vu". */
export function markStaticItemWatched(id: string, itemId: string): Promise<{ ok: true }> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/items/${encodeURIComponent(itemId)}/watched`, { method: 'POST' });
}
/** Launches the group's next session, or joins the one running. */
export function startStaticSession(id: string): Promise<{ partyId: string }> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/session`, { method: 'POST' });
}
/** Catches up on `itemIds` - alone, or as a watch party others can join. */
export function startCatchUp(id: string, itemIds: string[], mode: 'solo' | 'party'): Promise<{ partyId: string }> {
return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/catch-up`, { method: 'POST', body: JSON.stringify({ itemIds, mode }) });
}

View file

@ -10,7 +10,6 @@ import { renderArtist } from './views/artist/Artist';
import { renderAlbum } from './views/album/Album';
import { renderPlaylist } from './views/playlist/Playlist';
import { renderPartyPlayer } from './views/player/Player';
import { renderStaticParty } from './views/staticParty/StaticParty';
route('/', () => navigate('/home'));
route('/login', renderLogin);
@ -21,7 +20,6 @@ route('/artist/:id', renderArtist);
route('/album/:id', renderAlbum);
route('/playlist/:id', renderPlaylist);
route('/party/:id', renderPartyPlayer);
route('/static/:id', renderStaticParty);
/**
* Renders either the bare login screen or the full shell (sidebar +

View file

@ -171,42 +171,6 @@ export interface LobbyPartyDto {
positionTicks: number | null;
isPaused: boolean | null;
positionUpdatedUtc: number | null;
/** The static watch party this live session belongs to, if any. */
staticPartyId: string | null;
staticPartyName: string | null;
}
/** Mirrors server/src/types.ts's StaticPartySummaryDto - one static
* (persistent) watch party, as the home page lists it. */
export interface StaticPartySummaryDto {
id: string;
name: string;
sourceType: 'series' | 'playlist';
imageUrl: string;
itemCount: number;
groupWatchedCount: number;
nextItemName: string | null;
memberCount: number;
youAreMember: boolean;
youAreCreator: boolean;
missedCount: number;
livePartyId: string | null;
}
/** One item of a static watch party - mirrors StaticPartyItemDto. */
export interface StaticPartyItemDto {
itemId: string;
itemName: string;
itemImageUrl: string;
groupState: 'watched' | 'next' | 'upcoming';
viewerWatched: boolean;
}
/** Mirrors server/src/types.ts's StaticPartyDetailDto. */
export interface StaticPartyDetailDto extends StaticPartySummaryDto {
items: StaticPartyItemDto[];
missedItemIds: string[];
members: Array<{ userId: string; userName: string; watchedCount: number; isCreator: boolean }>;
}
export interface ItemChangedMsg {

View file

@ -60,41 +60,3 @@
color: var(--accent-bright);
font-weight: 600;
}
/* ---- Static (persistent) watch parties ----
A whole card is the link (to /static/:id) - reuses .partyCard's layout,
minus the default anchor styling. */
.staticCard {
color: inherit;
text-decoration: none;
}
.staticCard:hover { background: #2b252e; }
.staticCardLive {
color: var(--accent-bright);
font-size: 12px;
font-weight: 600;
}
.staticCardMissed {
flex-shrink: 0;
padding: 4px 10px;
border-radius: 12px;
background: var(--danger);
color: #fff;
font-size: 12px;
font-weight: 600;
}
/* A live session's link back to the static watch party it belongs to. */
.partyCardStatic {
color: var(--accent-bright);
font-size: 12px;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.partyCardStatic:hover { text-decoration: underline; }

View file

@ -7,9 +7,4 @@
<div id="partiesCarousel" class="carousel"></div>
</section>
<section class="section" id="staticSection" hidden>
<h2>Watch parties permanentes</h2>
<div id="staticCarousel" class="carousel"></div>
</section>
<div id="recentSections"></div>

View file

@ -7,8 +7,7 @@ import { navigate } from '../../router';
import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
import { listStaticParties } from '../../api/staticParty';
import type { LobbyPartyDto, StaticPartySummaryDto } from '../../types';
import type { LobbyPartyDto } from '../../types';
export function renderHome(root: HTMLElement): () => void {
root.innerHTML = html;
@ -19,20 +18,7 @@ export function renderHome(root: HTMLElement): () => void {
// label is the one piece that ticks purely client-side (from
// createdUtc) - no point re-pushing from the server once a second just
// for a clock.
// Static watch parties are a REST fetch (their data changes rarely), but
// re-fetched whenever the set of live static-party sessions in the lobby
// changes - a session starting/ending is what moves their cards'
// "en cours"/progress state. Not on every lobby snapshot: those also
// fire on every pause/join, and each static-party fetch reads Emby.
let staticSessionsKey: string | null = null;
const lobby = connectLobby((parties) => {
renderParties(root, parties);
const key = parties.map((p) => p.staticPartyId).filter(Boolean).sort().join(',');
if (key !== staticSessionsKey) {
staticSessionsKey = key;
loadStaticParties(root);
}
});
const lobby = connectLobby((parties) => renderParties(root, parties));
const elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000);
loadRecentSections(root);
@ -72,7 +58,6 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
<img class="${p.mediaType === 'music' ? 'partyCardArt--square' : ''}" src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
<div class="partyCardInfo">
<strong>${escapeHtml(p.itemName || '')}</strong>
${p.staticPartyId ? `<a class="partyCardStatic" href="#/static/${escapeHtml(p.staticPartyId)}">↻ ${escapeHtml(p.staticPartyName || '')}</a>` : ''}
<span class="hint">
${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}
· <span class="elapsed"
@ -108,39 +93,6 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
updateElapsedLabels(root);
}
/** Fetches and renders the "Watch parties permanentes" section - hidden
* when there are none. A failure only hides the section: the rest of the
* home page doesn't depend on it. */
function loadStaticParties(root: HTMLElement): void {
listStaticParties()
.then((parties) => renderStaticParties(root, parties))
.catch((err: unknown) => {
console.error('[home] static parties failed to load', err);
const section = root.querySelector('#staticSection') as HTMLElement | null;
if (section) section.hidden = true;
});
}
/** One card per static watch party: progress, the viewer's missed count,
* and whether a session is running right now. */
function renderStaticParties(root: HTMLElement, parties: StaticPartySummaryDto[]): void {
const section = root.querySelector('#staticSection') as HTMLElement | null;
const list = root.querySelector('#staticCarousel');
if (!section || !list) return; // navigated away mid-flight
section.hidden = !parties.length;
list.innerHTML = parties.map((p) => `
<a class="partyCard staticCard" href="#/static/${escapeHtml(p.id)}">
<img src="${escapeHtml(p.imageUrl)}" alt="" onerror="this.classList.add('noImage')" />
<div class="partyCardInfo">
<strong>${escapeHtml(p.name)}</strong>
<span class="hint">${p.groupWatchedCount}/${p.itemCount} vus · ${p.memberCount} membre${p.memberCount > 1 ? 's' : ''}</span>
${p.livePartyId ? '<span class="staticCardLive">● Séance en cours</span>' : ''}
</div>
${p.missedCount ? `<span class="staticCardMissed" title="Éléments manqués">${p.missedCount} manqué${p.missedCount > 1 ? 's' : ''}</span>` : ''}
</a>
`).join('');
}
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions
/**

View file

@ -691,10 +691,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
video.addEventListener('play', onDriverStateChanged);
video.addEventListener('pause', onDriverStateChanged);
video.addEventListener('seeked', onDriverStateChanged);
// The last item ending still counts as watched (Emby Played, a static
// watch party's progress) - "itemEnded" tells the server so, since
// there's no "next" to send for it.
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); else conn?.itemEnded(); });
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
if (!heartbeat) heartbeat = window.setInterval(reportHeartbeat, 3000);
// Burn-in (image) subtitle options are host-only (see

View file

@ -25,9 +25,8 @@
.playlistActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
gap: 16px;
margin-top: 4px;
}

View file

@ -6,7 +6,6 @@
<p class="hint" id="playlistMeta"></p>
<div class="playlistActions">
<button id="launchBtn">Lancer cette playlist</button>
<button class="hide" id="createStaticBtn">Créer une watch party permanente</button>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>

View file

@ -2,7 +2,6 @@ import './Playlist.css';
import html from './Playlist.html';
import { getItem, getPlaylistItems } from '../../api/embyApi';
import { createParty } from '../../api/party';
import { wireCreateStaticButton } from '../staticParty/createStaticButton';
import { joinMusicParty } from '../../musicBar/MusicBar';
import { navigate } from '../../router';
import type { RouteParams } from '../../router';
@ -57,15 +56,6 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr
return;
}
// Video playlists only - a static watch party has no music equivalent
// (the server refuses one too). Same "first item decides" rule as
// CatalogService.splitPlaylistByMediaType.
if (items[0]!.type !== 'Audio') {
const createStaticBtn = root.querySelector('#createStaticBtn') as HTMLButtonElement;
createStaticBtn.classList.remove('hide');
wireCreateStaticButton(createStaticBtn, playlistId);
}
listEl.innerHTML = items.map((item, i) => {
const meta = item.overview || [item.artistName, item.albumName].filter(Boolean).join(' · ');
const duration = item.durationTicks ? formatDuration(item.durationTicks / TICKS_PER_SECOND) : '';

View file

@ -17,14 +17,6 @@
.seriesPoster.noImage { visibility: hidden; }
.seriesActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
margin-top: 8px;
}
@media (max-width: 600px) {
.seriesHeader { flex-direction: column; }
.seriesPoster { width: 120px; }

View file

@ -4,11 +4,8 @@
<div class="seriesInfo">
<h1 id="seriesTitle"></h1>
<p class="seriesOverview hide" id="seriesOverview"></p>
<div class="seriesActions">
<button id="createStaticBtn">Créer une watch party permanente</button>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>
</div>
<nav id="seasonTabs" class="tabs"></nav>
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>

View file

@ -2,7 +2,6 @@ import './Series.css';
import html from './Series.html';
import { getItem, getSeriesEpisodes, getSeriesSeasons } from '../../api/embyApi';
import { createParty } from '../../api/party';
import { wireCreateStaticButton } from '../staticParty/createStaticButton';
import { navigate } from '../../router';
import type { RouteParams } from '../../router';
import { escapeHtml } from '../../tiles';
@ -35,7 +34,6 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom
overviewEl.classList.remove('hide');
}
(root.querySelector('#embyLink') as HTMLAnchorElement).href = series.embyUrl;
wireCreateStaticButton(root.querySelector('#createStaticBtn') as HTMLButtonElement, seriesId);
if (!seasons.length) {
root.querySelector('#episodeList')!.innerHTML = '<p class="hint">Aucune saison trouvée.</p>';

View file

@ -1,138 +0,0 @@
/* ---- Static (persistent) watch party page ----
.seriesInfo/.episodeList/.episodeRow/.episodeInfo are reused as-is from
views/series/Series.css (always in the bundle - same precedent as
Playlist.css/Album.css). Only what's specific to this page lives here. */
.staticHeader {
display: flex;
gap: 20px;
margin-bottom: 28px;
}
.staticCover {
width: 160px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
flex-shrink: 0;
}
.staticCover.noImage { visibility: hidden; }
.staticBadge {
display: inline-block;
font-size: 11px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--accent-bright);
margin-bottom: 6px;
}
.staticMeta { margin: 6px 0 10px; }
.staticProgress {
height: 6px;
max-width: 360px;
border-radius: 3px;
background: var(--panel);
overflow: hidden;
}
.staticProgressFill {
height: 100%;
width: 0;
background: var(--accent);
transition: width .3s;
}
.staticNext { margin: 10px 0 14px; font-size: 14px; }
.staticActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
}
/* A quieter variant of the global green button - for actions next to a
primary one (joining, catching up) that shouldn't compete with it. */
.secondaryBtn {
background: var(--panel);
color: var(--text);
}
.secondaryBtn:hover { background: #333; }
.staticSectionHead {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
}
.staticSectionHead h2 { margin: 0; }
.staticCount {
display: inline-block;
min-width: 22px;
padding: 1px 7px;
margin-left: 6px;
border-radius: 11px;
background: var(--danger);
color: #fff;
font-size: 12px;
text-align: center;
}
.staticBulkActions { display: flex; flex-wrap: wrap; gap: 8px; }
.staticBulkActions button,
.staticRowActions button { padding: 8px 12px; font-size: 13px; }
.staticMissed .episodeRow { border-left: 3px solid var(--danger); }
.staticCheck {
width: 18px;
height: 18px;
margin: 0;
flex-shrink: 0;
accent-color: var(--accent);
}
.staticRowActions { display: flex; flex-wrap: wrap; gap: 8px; flex-shrink: 0; }
/* Group-progress list: what the group has seen fades back, the next item
stands out. */
.episodeRow.isWatched { opacity: .55; }
.episodeRow.isNext { outline: 2px solid var(--accent); }
.staticState {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--muted);
text-align: right;
}
.staticState .mine { display: block; color: var(--accent-bright); font-weight: 400; }
.episodeRow.isNext .staticState { color: var(--accent-bright); }
.staticMembers { display: flex; flex-wrap: wrap; gap: 8px; }
.staticMember {
background: var(--bg-header);
border-radius: 16px;
padding: 6px 12px;
font-size: 13px;
}
.staticMember .hint { margin: 0 0 0 6px; font-size: 12px; }
@media (max-width: 600px) {
.staticHeader { flex-direction: column; }
.staticCover { width: 120px; }
.staticRowActions { flex-basis: 100%; order: 4; }
}

View file

@ -1,39 +0,0 @@
<button class="linklike" id="back">&larr; Retour</button>
<div class="staticHeader">
<img class="staticCover" id="staticCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo">
<span class="staticBadge">Watch party permanente</span>
<h1 id="staticTitle"></h1>
<p class="hint staticMeta" id="staticMeta"></p>
<div class="staticProgress" aria-hidden="true"><div class="staticProgressFill" id="staticProgressFill"></div></div>
<p class="staticNext" id="staticNext"></p>
<div class="staticActions">
<button id="sessionBtn">Lancer la séance</button>
<button class="secondaryBtn hide" id="joinBtn">Rejoindre cette watch party</button>
<button class="linklike hide" id="leaveBtn">Quitter</button>
<button class="linklike hide" id="deleteBtn">Supprimer</button>
</div>
</div>
</div>
<section class="section staticMissed" id="missedSection" hidden>
<div class="staticSectionHead">
<h2>Tu as manqué <span class="staticCount" id="missedCount"></span></h2>
<div class="staticBulkActions">
<button class="secondaryBtn" id="catchUpSoloBtn">Rattraper en solo</button>
<button class="secondaryBtn" id="catchUpPartyBtn">Lancer une watch party de rattrapage</button>
</div>
</div>
<p class="hint">Décoche ce que tu ne veux pas rattraper tout de suite.</p>
<div class="episodeList" id="missedList"></div>
</section>
<section class="section">
<h2>Membres</h2>
<div class="staticMembers" id="memberList"></div>
</section>
<section class="section">
<h2>Progression du groupe</h2>
<div class="episodeList" id="itemList"></div>
</section>

View file

@ -1,219 +0,0 @@
import './StaticParty.css';
import html from './StaticParty.html';
import {
deleteStaticParty,
getStaticParty,
joinStaticParty,
leaveStaticParty,
markStaticItemWatched,
startCatchUp,
startStaticSession
} from '../../api/staticParty';
import { navigate } from '../../router';
import type { RouteParams } from '../../router';
import { escapeHtml } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
import type { StaticPartyDetailDto, StaticPartyItemDto } from '../../types';
/**
* Route `/static/:id` - one static (persistent) watch party: the group's
* progress through the whole series/playlist, its members, and - the
* point of the feature - what *you* missed, with ways to catch up (alone,
* or as a watch party others can join) or mark things as already seen.
* Launching the group's session hands off to the normal player
* (`/party/:id`) - a session is an ordinary party server-side. See
* server/src/services/StaticPartyService.ts and spec/static-watch-party.md.
*/
export async function renderStaticParty(root: HTMLElement, params: RouteParams): Promise<void> {
const staticId = params.id as string;
root.innerHTML = LOADING_HINT;
let detail: StaticPartyDetailDto;
try {
detail = await getStaticParty(staticId);
} catch (err) {
console.error('[staticParty] load failed', staticId, err);
root.innerHTML = `<p class="error">${escapeHtml((err as Error).message || 'Impossible de charger cette watch party.')}</p>`;
return;
}
root.innerHTML = html;
root.querySelector('#back')!.addEventListener('click', () => history.back());
wireActions(root, staticId);
render(root, detail);
}
/** Re-fetches and re-renders in place - after any action that changed
* progress or membership. */
async function reload(root: HTMLElement, staticId: string): Promise<void> {
try {
const detail = await getStaticParty(staticId);
if (!root.querySelector('#staticTitle')) return; // navigated away mid-flight
render(root, detail);
} catch (err) {
console.error('[staticParty] reload failed', staticId, err);
showToast((err as Error).message, { variant: 'error' });
}
}
/** Fills every part of the page from `detail`. */
function render(root: HTMLElement, detail: StaticPartyDetailDto): void {
(root.querySelector('#staticCover') as HTMLImageElement).src = detail.imageUrl;
root.querySelector('#staticTitle')!.textContent = detail.name;
const sourceLabel = detail.sourceType === 'series' ? 'Série' : 'Playlist';
root.querySelector('#staticMeta')!.textContent =
`${sourceLabel} · ${detail.groupWatchedCount}/${detail.itemCount} vus par le groupe · ${detail.memberCount} membre${detail.memberCount > 1 ? 's' : ''}`;
const percent = detail.itemCount ? Math.round((detail.groupWatchedCount / detail.itemCount) * 100) : 0;
(root.querySelector('#staticProgressFill') as HTMLElement).style.width = `${percent}%`;
root.querySelector('#staticNext')!.innerHTML = detail.nextItemName
? `Prochain : <strong>${escapeHtml(detail.nextItemName)}</strong>`
: '<strong>Le groupe a tout vu.</strong>';
const sessionBtn = root.querySelector('#sessionBtn') as HTMLButtonElement;
sessionBtn.textContent = detail.livePartyId ? 'Rejoindre la séance en cours' : 'Lancer la séance';
sessionBtn.disabled = !detail.livePartyId && !detail.nextItemName;
root.querySelector('#joinBtn')!.classList.toggle('hide', detail.youAreMember);
root.querySelector('#leaveBtn')!.classList.toggle('hide', !detail.youAreMember || detail.youAreCreator);
root.querySelector('#deleteBtn')!.classList.toggle('hide', !detail.youAreCreator);
renderMissed(root, detail);
renderMembers(root, detail);
renderItems(root, detail);
}
/** "Tu as manqué" - hidden entirely when there's nothing to catch up on. */
function renderMissed(root: HTMLElement, detail: StaticPartyDetailDto): void {
const section = root.querySelector('#missedSection') as HTMLElement;
const missed = new Set(detail.missedItemIds);
const items = detail.items.filter((i) => missed.has(i.itemId));
section.hidden = !items.length;
root.querySelector('#missedCount')!.textContent = String(items.length);
root.querySelector('#missedList')!.innerHTML = items.map((item) => `
<div class="episodeRow">
<input type="checkbox" class="staticCheck" data-item-id="${escapeHtml(item.itemId)}" checked aria-label="Inclure dans le rattrapage" />
<img src="${escapeHtml(item.itemImageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="episodeInfo"><strong>${escapeHtml(item.itemName)}</strong></div>
<div class="staticRowActions">
<button class="secondaryBtn soloOneBtn" data-item-id="${escapeHtml(item.itemId)}">Regarder en solo</button>
<button class="secondaryBtn markWatchedBtn" data-item-id="${escapeHtml(item.itemId)}">Marquer comme vu</button>
</div>
</div>`).join('');
}
/** One chip per member, with how many items they've watched. */
function renderMembers(root: HTMLElement, detail: StaticPartyDetailDto): void {
root.querySelector('#memberList')!.innerHTML = detail.members.length
? detail.members.map((m) => `
<span class="staticMember">${escapeHtml(m.userName)}${m.isCreator ? ' ★' : ''}<span class="hint">${m.watchedCount}/${detail.itemCount}</span></span>`).join('')
: '<p class="hint">Aucun membre pour le moment.</p>';
}
/** The whole item list, with the group's state and the viewer's own. */
function renderItems(root: HTMLElement, detail: StaticPartyDetailDto): void {
root.querySelector('#itemList')!.innerHTML = detail.items.map((item, i) => `
<div class="episodeRow${item.groupState === 'watched' ? ' isWatched' : ''}${item.groupState === 'next' ? ' isNext' : ''}">
<img src="${escapeHtml(item.itemImageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="episodeInfo"><strong>${i + 1}. ${escapeHtml(item.itemName)}</strong></div>
<span class="staticState">${stateLabel(item)}${item.viewerWatched ? '<span class="mine">✓ vu par toi</span>' : ''}</span>
</div>`).join('');
}
/** The group-state label for one item. */
function stateLabel(item: StaticPartyItemDto): string {
if (item.groupState === 'watched') return 'Vu par le groupe';
if (item.groupState === 'next') return 'Prochain';
return 'À venir';
}
/**
* Wires every button once - lists are re-rendered on reload, so row
* buttons use one delegated listener on their container rather than
* per-row listeners re-added on every render.
*/
function wireActions(root: HTMLElement, staticId: string): void {
const sessionBtn = root.querySelector('#sessionBtn') as HTMLButtonElement;
sessionBtn.addEventListener('click', () => {
runBusy(sessionBtn, async () => {
const { partyId } = await startStaticSession(staticId);
navigate(`/party/${partyId}`);
});
});
const joinBtn = root.querySelector('#joinBtn') as HTMLButtonElement;
joinBtn.addEventListener('click', () => {
runBusy(joinBtn, async () => {
await joinStaticParty(staticId);
showToast('Tu fais maintenant partie de cette watch party.');
await reload(root, staticId);
});
});
const leaveBtn = root.querySelector('#leaveBtn') as HTMLButtonElement;
leaveBtn.addEventListener('click', () => {
if (!confirm('Quitter cette watch party ? Ta progression y sera oubliée.')) return;
runBusy(leaveBtn, async () => {
await leaveStaticParty(staticId);
await reload(root, staticId);
});
});
const deleteBtn = root.querySelector('#deleteBtn') as HTMLButtonElement;
deleteBtn.addEventListener('click', () => {
if (!confirm('Supprimer cette watch party pour tout le monde ? La progression de chacun sera perdue.')) return;
runBusy(deleteBtn, async () => {
await deleteStaticParty(staticId);
showToast('Watch party supprimée.');
navigate('/home');
});
});
const catchUp = (btn: HTMLButtonElement, mode: 'solo' | 'party') => {
const itemIds = Array.from(root.querySelectorAll<HTMLInputElement>('#missedList .staticCheck'))
.filter((c) => c.checked)
.map((c) => c.dataset.itemId as string);
if (!itemIds.length) {
showToast('Coche au moins un élément à rattraper.', { variant: 'error' });
return;
}
runBusy(btn, async () => {
const { partyId } = await startCatchUp(staticId, itemIds, mode);
navigate(`/party/${partyId}`);
});
};
const soloBtn = root.querySelector('#catchUpSoloBtn') as HTMLButtonElement;
const partyBtn = root.querySelector('#catchUpPartyBtn') as HTMLButtonElement;
soloBtn.addEventListener('click', () => catchUp(soloBtn, 'solo'));
partyBtn.addEventListener('click', () => catchUp(partyBtn, 'party'));
root.querySelector('#missedList')!.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('button[data-item-id]');
if (!btn) return;
const itemId = btn.dataset.itemId as string;
if (btn.classList.contains('soloOneBtn')) {
runBusy(btn, async () => {
const { partyId } = await startCatchUp(staticId, [itemId], 'solo');
navigate(`/party/${partyId}`);
});
} else if (btn.classList.contains('markWatchedBtn')) {
runBusy(btn, async () => {
await markStaticItemWatched(staticId, itemId);
await reload(root, staticId);
});
}
});
}
/** Disables `btn` while `action` runs; a failure is toasted, logged, and
* gives the button back. */
function runBusy(btn: HTMLButtonElement, action: () => Promise<void>): void {
btn.disabled = true;
action()
.catch((err: unknown) => {
console.error('[staticParty] action failed', err);
showToast((err as Error).message, { variant: 'error' });
})
.finally(() => {
btn.disabled = false;
});
}

View file

@ -1,28 +0,0 @@
import { createStaticParty } from '../../api/staticParty';
import { navigate } from '../../router';
import { showToast } from '../../toast';
/**
* "Créer une watch party permanente" - shared by views/series and
* views/playlist: creates a static watch party over `sourceId` (a series
* or a video playlist - the server decides which, and refuses anything
* else with a message) and opens its page.
*/
export function wireCreateStaticButton(btn: HTMLButtonElement, sourceId: string): void {
const idleLabel = btn.textContent || '';
btn.addEventListener('click', () => {
btn.disabled = true;
btn.textContent = 'Création...';
createStaticParty(sourceId)
.then((sp) => {
console.info('[staticParty] created', sp.id, 'from', sourceId);
navigate(`/static/${sp.id}`);
})
.catch((err: unknown) => {
console.error('[staticParty] create failed', sourceId, err);
showToast((err as Error).message, { variant: 'error' });
btn.disabled = false;
btn.textContent = idleLabel;
});
});
}

View file

@ -8,9 +8,3 @@ services:
- "3000:3000"
environment:
EMBY_SERVER_URL: "https://theater.media.kyuno.fr"
# Static (persistent) watch parties are saved here - see
# server/src/services/StaticPartyStore.ts. Without the volume below
# they'd be lost every time the container is recreated.
DATA_DIR: "/data"
volumes:
- ./data:/data

View file

@ -4562,34 +4562,6 @@ would have overflowed by well over 200px. A separate portrait check
(375x700) confirmed this landscape block changes nothing there: still
`column`, still the original 220px `vw`-capped art, unaffected.
## Static (persistent) watch parties
Asked for directly: a watch party built from a whole series or video
playlist that remembers the group's progress *and* each person's, so
someone coming back or joining late sees what they missed. Full design
in [`spec/static-watch-party.md`](../spec/static-watch-party.md). The
short version:
- **A new persistent entity, not a change to Party.**
`StaticPartyService` owns the group and member progress. Each session
is an ordinary live `Party` linked back through `party.staticLink`,
so the player, sync, shared stream, lobby and host handoff are all
reused unchanged. `PartyService` calls into `StaticPartyService`,
never the other way around.
- **This app's first on-disk state.** A JSON file
(`StaticPartyStore`, `DATA_DIR`) that is atomic, debounced, and
flushed on SIGTERM. "Party lifecycle" above still holds for ordinary
parties.
- **"Completed" gained a second trigger.** `PartyService
.completeCurrentItem` is now the one place an item counts as done. It
handles Emby Played, as before, plus static progress. It runs from
`advanceQueue`, and from a new host-only `"itemEnded"` socket event
for the *last* queued item. That item was previously never marked
Played at all, since there was no "next" to send for it.
- **Missed = group-completed − watched in-app − Emby Played**, computed
by the pure `computeMissedIds`. Each viewer's own Emby `UserData` is
read from the source re-fetch under their own session.
## Known limitations
- Playback always goes through `hls.js` against whatever Emby's
@ -4606,10 +4578,7 @@ short version:
resume), so two listeners' `<audio>` elements could drift apart slightly
during a long stretch with no such event - accepted for v1 given music's
more forgiving tolerance for exact sync compared to video+subtitles.
- No persistence for ordinary parties: server restart or long idle time
ends every one. Only static watch parties (see their own section) are
persisted - and even their live sessions end on a restart, resuming
from the last saved position.
- No persistence: server restart or long idle time ends every party.
- No free seeking within a music track (only vote-skip to the *next* one)
- not asked for, and would need its own protocol addition
(`MusicQueueService` has no notion of "jump to position X" today, only

View file

@ -46,10 +46,6 @@ COPY --from=build /app/client/dist ./client/dist
WORKDIR /app/server
ENV PORT=3000
# Static watch parties' JSON file (StaticPartyStore) - mount a volume here
# (docker-compose.yml does), or they're lost with the container.
ENV DATA_DIR=/data
RUN mkdir -p /data
EXPOSE 3000
CMD ["node", "dist/index.js"]

View file

@ -9,7 +9,7 @@
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"typecheck": "tsc -p tsconfig.test.json",
"test": "tsx --test test/castTokenService.test.ts test/catalogService.test.ts test/embyClient.test.ts test/embySessionService.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/staticPartyService.test.ts test/transcodeService.test.ts"
"test": "tsx --test test/castTokenService.test.ts test/catalogService.test.ts test/embyClient.test.ts test/embySessionService.test.ts test/musicQueueService.test.ts test/partyService.test.ts test/partyStreamService.test.ts test/playstateService.test.ts test/socketGateway.test.ts test/transcodeService.test.ts"
},
"engines": {
"node": ">=18"

View file

@ -83,11 +83,4 @@ export class Logger {
static playstate(message: string): void {
console.log(`[watchparty:playstate] ${new Date().toISOString()} ${message}`);
}
/** Static (persistent) watch parties - created/joined/left/deleted,
* group and per-member progress, sessions launched, and every load/save
* of their JSON file (see StaticPartyService/StaticPartyStore). */
static staticParty(message: string): void {
console.log(`[watchparty:static] ${new Date().toISOString()} ${message}`);
}
}

View file

@ -17,11 +17,3 @@ if (!EMBY_SERVER_URL) {
}
export const PORT = Number(process.env.PORT) || 3000;
/**
* Where persistent state lives on disk - today only the static watch
* parties (see StaticPartyStore). Relative paths resolve against the
* process's working directory. Mount a volume here in Docker, or every
* static watch party is lost with the container.
*/
export const DATA_DIR = process.env.DATA_DIR || './data';

View file

@ -5,7 +5,7 @@ import http from 'http';
import express, { type NextFunction, type Request, type Response } from 'express';
import { Server as SocketIoServer } from 'socket.io';
import { DATA_DIR, EMBY_SERVER_URL, PORT } from './config';
import { EMBY_SERVER_URL, PORT } from './config';
import { Logger } from './Logger';
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService';
import { CatalogService, CatalogFetchError, imageUrlFor, splitPlaylistByMediaType } from './services/CatalogService';
@ -16,13 +16,8 @@ import { PartyStreamService } from './services/PartyStreamService';
import { PlaystateService } from './services/PlaystateService';
import { EmbySessionService } from './services/EmbySessionService';
import { CastTokenService } from './services/CastTokenService';
import { StaticPartyService } from './services/StaticPartyService';
import { StaticPartyStore } from './services/StaticPartyStore';
import { SocketGateway } from './realtime/SocketGateway';
import type { ClientToServerEvents, EmbyItem, EmbySession, ItemDto, MediaType, MusicTrackInput, QueueItem, ServerToClientEvents, StaticParty } from './types';
StaticPartyStore.configure(DATA_DIR);
StaticPartyService.init();
import type { ClientToServerEvents, EmbyItem, EmbySession, ItemDto, MediaType, MusicTrackInput, QueueItem, ServerToClientEvents } from './types';
const app = express();
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
@ -374,7 +369,11 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId);
const startIndex = all.findIndex((e) => e.Id === itemId);
const remaining = startIndex >= 0 ? all.slice(startIndex) : [item];
queue = remaining.map(toVideoQueueItem);
queue = remaining.map((e) => ({
itemId: e.Id,
itemName: formatEpisodeName(e),
itemImageUrl: `/api/catalog/image/${e.Id}`
}));
}
const party = PartyService.create(
@ -411,17 +410,6 @@ function formatEpisodeName(episode: { ParentIndexNumber?: number; IndexNumber?:
return episode.Name;
}
/** One video item (episode or movie) as a queue entry - shared by every
* path that builds a video queue: an episode's series, a playlist, a
* static watch party. */
function toVideoQueueItem(item: EmbyItem): QueueItem {
return {
itemId: item.Id,
itemName: item.Type === 'Episode' ? formatEpisodeName(item) : item.Name,
itemImageUrl: `/api/catalog/image/${item.Id}`
};
}
/**
* The "launch this playlist" half of POST /api/parties above - kept
* separate rather than merged into the single-item path, which is
@ -468,7 +456,11 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
return;
}
const queue: QueueItem[] = items.map(toVideoQueueItem);
const queue: QueueItem[] = items.map((e) => ({
itemId: e.Id,
itemName: e.Type === 'Episode' ? formatEpisodeName(e) : e.Name,
itemImageUrl: `/api/catalog/image/${e.Id}`
}));
const party = PartyService.create(
session.userId, session.userName,
queue[0]!.itemId, queue[0]!.itemName, queue[0]!.itemImageUrl,
@ -616,12 +608,6 @@ app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
const session = req.embySession!;
PartyService.join(party, session.userId, session.userName);
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
// Joining a static watch party's group session from the lobby makes you
// a member of that static party too - its progress is now yours.
if (party.staticLink?.mode === 'group') {
const sp = StaticPartyService.getById(party.staticLink.staticPartyId);
if (sp) StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
}
SocketGateway.broadcastLobby(io); // member count changed - reflect it on the home page immediately
res.json(PartyService.toPublicState(party, session.userId));
});
@ -676,213 +662,6 @@ app.delete('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
res.json({ ok: true });
});
// --- Static (persistent) watch parties ------------------------------------
// A whole series/video playlist watched by a group over several sessions,
// remembering the group's progress and each member's - see
// StaticPartyService and spec/static-watch-party.md. Each session is an
// ordinary live party (PartyService) linked back through party.staticLink.
/** Fetches a static party's source (every episode of the series, or the
* playlist's video items) under `session`'s own account - so each item
* carries *their* Emby UserData (Played). */
async function fetchStaticSourceItems(session: EmbySession, source: StaticParty['source']): Promise<EmbyItem[]> {
if (source.type === 'series') {
return CatalogService.getAllSeriesEpisodesRaw(session, source.sourceId);
}
const { mediaType, items } = splitPlaylistByMediaType(await CatalogService.getPlaylistItemsRaw(session, source.sourceId));
return mediaType === 'video' ? items : [];
}
/**
* Re-fetches `sp`'s source for `session`: merges any new item into it
* (StaticPartyService.refreshItems) and returns the ids Emby already has
* as Played for this user. Best-effort - Emby being unreachable must not
* make the page unusable, so a failure is logged and treated as "no
* Played info, no new items" (the in-app progress still works on its own).
*/
async function syncStaticSource(session: EmbySession, sp: StaticParty): Promise<Set<string>> {
try {
const raw = await fetchStaticSourceItems(session, sp.source);
StaticPartyService.refreshItems(sp, raw.map(toVideoQueueItem));
return new Set(raw.filter((i) => i.UserData?.Played).map((i) => i.Id));
} catch (err) {
Logger.staticParty(`source sync failed id=${sp.id} user=${Logger.who(session.userId, session.userName)}: ${(err as Error).message}`);
return new Set();
}
}
/** `sp`'s live group session id, if that party is still running - clears a
* stale one (a party can end without its static party having heard). */
function liveGroupPartyId(sp: StaticParty): string | null {
if (sp.livePartyId && !PartyService.getById(sp.livePartyId)) StaticPartyService.setLiveParty(sp, null);
return sp.livePartyId;
}
/** Finds `:staticId` or answers 404 - null means the response is sent. */
function staticPartyOr404(req: Request, res: Response): StaticParty | null {
const sp = StaticPartyService.getById(param(req, 'staticId'));
if (!sp) res.status(404).json({ error: 'Watch party permanente introuvable.' });
return sp;
}
app.get('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
const session = req.embySession!;
const parties = StaticPartyService.list();
const played = await Promise.all(parties.map((sp) => syncStaticSource(session, sp)));
res.json({
staticParties: parties.map((sp, i) => StaticPartyService.toSummary(sp, session.userId, played[i]!, liveGroupPartyId(sp)))
});
}));
app.post('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
const sourceId = req.body?.sourceId;
if (!sourceId || typeof sourceId !== 'string') {
res.status(400).json({ error: 'sourceId is required.' });
return;
}
const session = req.embySession!;
const item = await CatalogService.getRawItem(session, sourceId);
// An episode stands for its whole series - the natural entry point from
// views/series is the series itself, but nothing's lost accepting one.
let source: StaticParty['source'];
let sourceItem = item;
if (item.Type === 'Series') {
source = { type: 'series', sourceId: item.Id };
} else if (item.Type === 'Episode' && item.SeriesId) {
source = { type: 'series', sourceId: item.SeriesId };
sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
} else if (item.Type === 'Playlist') {
source = { type: 'playlist', sourceId: item.Id };
} else {
res.status(400).json({ error: 'Une watch party permanente se crée depuis une série ou une playlist vidéo.' });
return;
}
const raw = await fetchStaticSourceItems(session, source);
if (!raw.length) {
res.status(400).json({ error: source.type === 'playlist' ? 'Cette playlist ne contient aucune vidéo.' : "Cette série n'a aucun épisode." });
return;
}
const name = typeof req.body?.name === 'string' && req.body.name.trim() ? req.body.name.trim() : sourceItem.Name;
const sp = StaticPartyService.create(
{ userId: session.userId, userName: session.userName },
source, name, imageUrlFor(sourceItem), raw.map(toVideoQueueItem)
);
const played = new Set(raw.filter((i) => i.UserData?.Played).map((i) => i.Id));
res.json(StaticPartyService.toDetail(sp, session.userId, played, null));
}));
app.get('/api/static-parties/:staticId', requireSession, asyncRoute(async (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
const played = await syncStaticSource(session, sp);
res.json(StaticPartyService.toDetail(sp, session.userId, played, liveGroupPartyId(sp)));
}));
app.post('/api/static-parties/:staticId/join', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
res.json({ ok: true });
});
app.post('/api/static-parties/:staticId/leave', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
StaticPartyService.leave(sp, req.embySession!.userId);
res.json({ ok: true });
});
app.delete('/api/static-parties/:staticId', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
if (!StaticPartyService.delete(sp, req.embySession!.userId)) {
res.status(403).json({ error: 'Seule la personne qui a créé cette watch party peut la supprimer.' });
return;
}
res.json({ ok: true });
});
app.post('/api/static-parties/:staticId/items/:itemId/watched', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
if (!StaticPartyService.markWatched(sp, { userId: session.userId, userName: session.userName }, param(req, 'itemId'))) {
res.status(404).json({ error: 'Cet élément ne fait pas partie de cette watch party.' });
return;
}
res.json({ ok: true });
});
// Launches the group's next session - or joins it, if one's already
// running. Whoever launches it hosts it (see PartyService.create), and it
// resumes exactly where the group stopped last time: the host's starting
// position is seeded from the saved one, which is what Player.ts already
// reads to place every member's first frame (see its init()).
app.post('/api/static-parties/:staticId/session', requireSession, asyncRoute(async (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
const who = { userId: session.userId, userName: session.userName };
const existingId = liveGroupPartyId(sp);
if (!existingId) await syncStaticSource(session, sp); // pick up new episodes before building the queue
// Checked again after the await: someone else may have launched it meanwhile.
const liveId = liveGroupPartyId(sp);
const live = liveId ? PartyService.getById(liveId) : null;
if (live) {
PartyService.join(live, session.userId, session.userName);
PartyService.setMemberEmbyToken(live, session.userId, session.embyToken);
StaticPartyService.join(sp, who);
SocketGateway.broadcastLobby(io);
res.json({ partyId: live.id });
return;
}
const queue = StaticPartyService.groupQueue(sp);
if (!queue.length) {
res.status(409).json({ error: 'Le groupe a déjà tout vu dans cette watch party.' });
return;
}
const first = queue[0]!;
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
party.staticLink = { staticPartyId: sp.id, mode: 'group' };
party.members.get(session.userId)!.positionTicks = sp.group.resumePositionTicks;
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
StaticPartyService.join(sp, who);
StaticPartyService.setLiveParty(sp, party.id);
SocketGateway.broadcastLobby(io);
res.json({ partyId: party.id });
}));
// Catching up on missed items - alone (a party hidden from the lobby) or as
// an ordinary watch party others can join. Only the participants' own
// progress changes; the group's doesn't (see StaticPartyService.onItemCompleted).
app.post('/api/static-parties/:staticId/catch-up', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
const session = req.embySession!;
const mode = req.body?.mode === 'party' ? 'party' : 'solo';
const requested = new Set(Array.isArray(req.body?.itemIds) ? req.body.itemIds.filter((id: unknown) => typeof id === 'string') : []);
const queue = sp.items.filter((i) => requested.has(i.itemId)); // the static party's own order, never the caller's
if (!queue.length) {
res.status(400).json({ error: 'Aucun élément à rattraper.' });
return;
}
const first = queue[0]!;
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
party.staticLink = { staticPartyId: sp.id, mode: 'catchUp' };
party.hiddenFromLobby = mode === 'solo';
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
StaticPartyService.join(sp, { userId: session.userId, userName: session.userName });
Logger.staticParty(`catch-up started id=${sp.id} mode=${mode} partyId=${party.id} items=${queue.length} by=${Logger.who(session.userId, session.userName)}`);
if (!party.hiddenFromLobby) SocketGateway.broadcastLobby(io);
res.json({ partyId: party.id });
});
// Catches whatever asyncRoute() forwarded (or a synchronous throw) -
// CatalogFetchError carries its own status/message; anything else is an
// unexpected bug, logged in full server-side but never leaked to the
@ -951,9 +730,6 @@ setInterval(() => {
for (const party of PartyService.listActive()) {
const state = PartyService.getCurrentPlaybackState(party);
if (!state) continue; // nothing currently playing (e.g. an empty music queue)
// A static watch party's group session: remember where the group is,
// so the next session resumes there (no-op for any other party).
if (party.staticLink) StaticPartyService.recordPosition(party.staticLink, state.itemId, state.positionTicks);
for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue;
PlaystateService.updatePosition(member.embyToken, member.userId, state.itemId, state.positionTicks);
@ -990,16 +766,6 @@ setInterval(() => {
}
}, 20 * 1000);
// A pending (debounced) static-party save must reach disk before the
// process exits - `docker stop` sends SIGTERM, Ctrl+C sends SIGINT.
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => {
Logger.staticParty(`${signal} received - flushing before exit`);
StaticPartyStore.flushSync();
process.exit(0);
});
}
server.listen(PORT, () => {
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
});

View file

@ -3,7 +3,6 @@
import { Logger } from '../Logger';
import { MusicQueueService } from '../services/MusicQueueService';
import { PartyService } from '../services/PartyService';
import { StaticPartyService } from '../services/StaticPartyService';
import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } from '../types';
/**
@ -36,9 +35,6 @@ import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } fr
* "playstate" {isPaused, positionTicks} (host only - ignored otherwise)
* "playstate" (server -> client, to everyone in the party but the sender)
* "next" (host only - advance the queue)
* "itemEnded" (host only - the last queued item
* played to its end; counts it as
* completed without advancing)
* "itemChanged" (server -> client)
* "setBurnInSubtitle" {subtitleIndex, audioIndex?} (host only - picks
* or clears the party's *shared* self-hosted-ffmpeg burn-in stream,
@ -211,17 +207,6 @@ class SocketGatewayClass {
}
}));
// The last queued item played to its end - nothing to advance to,
// but it still counts as completed (Emby Played flag, static watch
// party progress). Host-only, same as "next".
socket.on('itemEnded', this.safe(() => {
if (!party || userId !== party.hostUserId) {
return;
}
Logger.party(`item ended partyId=${party.id} item="${party.itemName}" (${party.queueIndex + 1}/${party.queue.length})`);
PartyService.completeCurrentItem(party);
}));
// Host-only, video-only: picks (or clears) the party's *shared*
// burn-in stream - see TranscodeService and Party.burnInSubtitleIndex's
// own comment. Deliberately doesn't touch TranscodeService directly
@ -425,11 +410,8 @@ class SocketGatewayClass {
}
private lobbySnapshot(): LobbyPartyDto[] {
// A solo catch-up session of a static watch party isn't something
// anyone else should see or join - see Party.hiddenFromLobby.
return PartyService.listActive().filter((party) => !party.hiddenFromLobby).map((party) => {
return PartyService.listActive().map((party) => {
const host = party.members.get(party.hostUserId);
const staticParty = party.staticLink ? StaticPartyService.getById(party.staticLink.staticPartyId) : null;
// Video only - see LobbyPartyDto's own comment for why music parties
// get null here and fall back to createdUtc client-side instead.
// The host is authoritative for position/pause the same way they are
@ -447,9 +429,7 @@ class SocketGatewayClass {
createdUtc: party.createdUtc, // client computes/ticks elapsed time itself from this
positionTicks: isVideo && host ? host.positionTicks : null,
isPaused: isVideo && host ? host.isPaused : null,
positionUpdatedUtc: isVideo && host ? host.lastUpdatedUtc : null,
staticPartyId: staticParty ? staticParty.id : null,
staticPartyName: staticParty ? staticParty.name : null
positionUpdatedUtc: isVideo && host ? host.lastUpdatedUtc : null
};
});
}

View file

@ -8,7 +8,6 @@ import { PartyStreamService } from './PartyStreamService';
import { PlaystateService } from './PlaystateService';
import { CastTokenService } from './CastTokenService';
import { EmbySessionService } from './EmbySessionService';
import { StaticPartyService } from './StaticPartyService';
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
/**
@ -210,7 +209,6 @@ class PartyServiceClass {
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
TranscodeService.endSession(party.id); // no-op if this party never had a burn-in stream running
PartyStreamService.endSession(party.id); // same - no-op if this party never negotiated a shared stream
if (party.staticLink) StaticPartyService.onPartyDestroyed(party.staticLink, party.id);
Logger.party(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
} else if (wasHost && newHostUserId != null) {
const newHost = party.members.get(newHostUserId)!;
@ -232,9 +230,7 @@ class PartyServiceClass {
if (party.queueIndex >= party.queue.length - 1) {
return false;
}
// Whatever was just playing is "done" - before the party's own state
// moves on. See completeCurrentItem's own comment.
this.completeCurrentItem(party);
const outgoingItemId = party.itemId; // whatever was just playing - see the markPlayed loop below
party.queueIndex += 1;
const next = party.queue[party.queueIndex]!;
party.itemId = next.itemId;
@ -245,6 +241,18 @@ class PartyServiceClass {
member.isPaused = false;
member.lastUpdatedUtc = Date.now();
}
// The party moving on from `outgoingItemId` is this app's own signal
// that it's "done" - see PlaystateService.markPlayed's own comment
// for why that's a heuristic, not a real "watched it all" check, and
// why every currently-connected member gets their own account
// updated, not just the host. Fire-and-forget, same as the periodic
// position sync in index.ts - a member's bad token or a transient
// Emby hiccup here must never block the party from actually moving
// on to the next item.
for (const member of party.members.values()) {
if (!member.connection || !member.embyToken) continue;
PlaystateService.markPlayed(member.embyToken, member.userId, outgoingItemId);
}
// A new item's own MediaStreams indices have nothing to do with the
// old one's - carrying a burn-in or text subtitle pick across would
// show whatever track happens to share that index on the new item,
@ -269,40 +277,6 @@ class PartyServiceClass {
return true;
}
/**
* Marks the party's current item as "done": the party moving on from it
* (advanceQueue) or the last queued item playing to its end (the host's
* "itemEnded" - see SocketGateway) is this app's own signal for that -
* see PlaystateService.markPlayed's own comment for why it's a
* heuristic, not a real "watched it all" check, and why every
* currently-connected member gets their own account updated, not just
* the host. Fire-and-forget, same as the periodic position sync in
* index.ts - a member's bad token or a transient Emby hiccup here must
* never block the party from actually moving on.
*
* For a session of a static watch party, also records the item as
* completed there (StaticPartyService.onItemCompleted) for every
* connected member. Idempotent per item (`lastCompletedItemId`), so an
* "itemEnded" and a later "next" on the same item never count twice.
*/
completeCurrentItem(party: Party): void {
const itemId = party.itemId;
if (party.lastCompletedItemId === itemId) return;
party.lastCompletedItemId = itemId;
const connected = Array.from(party.members.values()).filter((m) => !!m.connection);
for (const member of connected) {
if (!member.embyToken) continue;
PlaystateService.markPlayed(member.embyToken, member.userId, itemId);
}
if (party.staticLink) {
StaticPartyService.onItemCompleted(
party.staticLink,
itemId,
connected.map((m) => ({ userId: m.userId, userName: m.userName }))
);
}
}
/** All active parties - powers the "watch parties en cours" home section. */
listActive(): Party[] {
return Array.from(this.partiesById.values());
@ -316,7 +290,6 @@ class PartyServiceClass {
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
TranscodeService.endSession(party.id); // same leak - see the "leave" destroy path above
PartyStreamService.endSession(party.id);
if (party.staticLink) StaticPartyService.onPartyDestroyed(party.staticLink, party.id);
// Same reasoning, for any member whose own "leave" (a clean
// disconnect) never fired for whatever reason - a real gap would
// otherwise leave a stale entry sitting in Emby's own dashboard

View file

@ -1,316 +0,0 @@
'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.
*/
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();
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();
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();
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();
}
/** 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}"`);
this.persist();
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();
}
/** 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();
}
}
/**
* 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();
}
/** 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();
}
/** 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();
}
/** "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();
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 every party to disk (debounced - see StaticPartyStore). */
private persist(): void {
StaticPartyStore.save(() => this.snapshot());
}
/** Every party, minus runtime-only state. */
private snapshot(): PersistedStaticParty[] {
return Array.from(this.partiesById.values()).map(({ livePartyId: _live, ...persisted }) => 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();

View file

@ -1,146 +0,0 @@
'use strict';
import fs from 'fs';
import path from 'path';
import { Logger } from '../Logger';
import type { PersistedStaticParty } from '../types';
/** The on-disk file's own shape - versioned so a future format change can
* migrate instead of silently misreading an older file. */
interface StaticPartyFile {
version: 1;
parties: PersistedStaticParty[];
}
/** How long a save waits for more changes before writing - progress
* updates arrive in bursts (a whole party's members at once, the 20s
* position sweep), no reason to rewrite the file for each one. */
const SAVE_DEBOUNCE_MS = 2000;
/**
* The static watch parties' persistence - one JSON file, the first and
* only thing this app keeps on disk (everything else is deliberately
* in-memory - see docs/architecture.md's "Party lifecycle"). A JSON file
* rather than a database: the data is tiny (a handful of parties, a few
* hundred item ids each), the runtime image is Node 20 (no built-in
* `node:sqlite`), and a native SQLite binding would be the image's first
* compiled dependency - not worth it at this size.
*
* Writes are atomic (write a `.tmp` sibling, then rename over the real
* file) so a crash mid-write never leaves a half-written file behind, and
* debounced (SAVE_DEBOUNCE_MS). A missing file is a normal first start; an
* unreadable/corrupt one is logged and treated as empty - the server must
* never refuse to start over it. The corrupt file is kept aside (`.corrupt`)
* rather than overwritten by the next save, so nothing is lost for good.
*/
class StaticPartyStoreClass {
/** Absolute path of the JSON file - see configure(). */
private filePath: string | null = null;
/** The pending debounced save, if any. */
private saveTimer: NodeJS.Timeout | null = null;
/** What the pending save will write - always the latest snapshot. */
private pending: (() => PersistedStaticParty[]) | null = null;
/** Points the store at `dataDir/static-parties.json`. Must be called
* before load()/save() - index.ts does it at startup, tests with a
* temp directory. */
public configure(dataDir: string): void {
this.filePath = path.resolve(dataDir, 'static-parties.json');
}
/** Reads every persisted party - synchronous, startup-only. */
public load(): PersistedStaticParty[] {
const file = this.requirePath();
let raw: string;
try {
raw = fs.readFileSync(file, 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
Logger.staticParty(`no data file yet at ${file} - starting empty`);
} else {
Logger.staticParty(`could not read ${file} - starting empty: ${(err as Error).message}`);
}
return [];
}
try {
const parsed = JSON.parse(raw) as Partial<StaticPartyFile>;
if (!parsed || !Array.isArray(parsed.parties)) throw new Error('missing "parties" array');
Logger.staticParty(`loaded ${parsed.parties.length} static party(ies) from ${file}`);
return parsed.parties;
} catch (err) {
Logger.staticParty(`corrupt data file ${file} - moved aside to .corrupt, starting empty: ${(err as Error).message}`);
try {
fs.renameSync(file, `${file}.corrupt`);
} catch (renameErr) {
Logger.staticParty(`could not move corrupt file aside: ${(renameErr as Error).message}`);
}
return [];
}
}
/** Schedules a debounced write of whatever `snapshot` returns at write
* time (so a burst of changes only ever writes the final state). */
public save(snapshot: () => PersistedStaticParty[]): void {
this.pending = snapshot;
if (this.saveTimer) return;
this.saveTimer = setTimeout(() => {
this.saveTimer = null;
this.writeNow().catch((err: unknown) => {
Logger.staticParty(`save failed: ${(err as Error).message}`);
});
}, SAVE_DEBOUNCE_MS);
this.saveTimer.unref(); // a pending save must never keep a test process (or a shutdown) alive
}
/** Writes any pending save immediately and synchronously - for a
* graceful shutdown (SIGTERM/SIGINT), where an async write could be
* cut short by the process exiting. */
public flushSync(): void {
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
if (!this.pending) return;
const file = this.requirePath();
const snapshot = this.pending;
this.pending = null;
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
fs.renameSync(`${file}.tmp`, file);
Logger.staticParty(`flushed to ${file}`);
} catch (err) {
Logger.staticParty(`flush failed: ${(err as Error).message}`);
}
}
/** Writes the pending snapshot now - atomically, via a `.tmp` rename. */
public async writeNow(): Promise<void> {
if (!this.pending) return;
const file = this.requirePath();
const snapshot = this.pending;
this.pending = null;
try {
await fs.promises.mkdir(path.dirname(file), { recursive: true });
await fs.promises.writeFile(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
await fs.promises.rename(`${file}.tmp`, file);
} catch (err) {
Logger.staticParty(`write to ${file} failed: ${(err as Error).message}`);
throw err;
}
}
/** The file's JSON text for `parties`. */
private serialize(parties: PersistedStaticParty[]): string {
const data: StaticPartyFile = { version: 1, parties };
return JSON.stringify(data, null, 2);
}
/** The configured file path - throws if configure() was never called. */
private requirePath(): string {
if (!this.filePath) throw new Error('StaticPartyStore.configure() was never called');
return this.filePath;
}
}
export const StaticPartyStore = new StaticPartyStoreClass();

View file

@ -29,11 +29,6 @@ export interface ClientToServerEvents {
join: (msg: { partyId: string; deviceId: string; force?: boolean }) => void;
playstate: (msg: { isPaused: boolean; positionTicks: number }) => void;
next: () => void;
/** Host-only (checked server-side exactly like "next") - the last
* queued item played to its end, so there's no "next" to send. Lets
* the server count that final item as completed (PartyService
* .completeCurrentItem) without advancing anything. */
itemEnded: () => void;
voteSkip: () => void;
musicPlaystate: (msg: { isPaused: boolean }) => void;
/** No host check, same as "musicPlaystate" - see MusicQueueService's own
@ -185,11 +180,6 @@ export interface EmbyItem {
* one sampled 500'd), only its album does - this is how an image URL
* falls back to the album's art instead. */
AlbumId?: string;
/** The requesting user's own per-item state - Emby embeds it on every
* item fetched with a userId, unasked (see PlaystateService's own
* comment). Only `Played` is read, by the static watch party's
* "missed items" computation (StaticPartyService). */
UserData?: { Played?: boolean };
}
/** This app's own DTO shape - what toItemDto() in CatalogService produces,
@ -369,25 +359,6 @@ export interface Party {
* MusicBar.ts never had this picker in the first place. */
streamAudioIndex: string | null;
streamQuality: string | null;
/** Set only for a live session launched from a static watch party (see
* StaticPartyService) - absent for every ordinary party. */
staticLink?: StaticPartyLink;
/** Kept out of the home page's live lobby list (SocketGateway
* .lobbySnapshot) - a solo catch-up session of a static watch party. */
hiddenFromLobby?: boolean;
/** The last item this party already reported as completed (see
* PartyService.completeCurrentItem) - guards against an "itemEnded"
* followed by a "next" on the same item counting it twice. */
lastCompletedItemId?: string | null;
}
/** How a live party relates to the static watch party it was launched
* from - `group` is the group's own session (advances the group's
* progress), `catchUp` is someone catching up on items they missed
* (only marks those items watched for whoever was connected). */
export interface StaticPartyLink {
staticPartyId: string;
mode: 'group' | 'catchUp';
}
export interface PublicMember {
@ -444,85 +415,6 @@ export interface LobbyPartyDto {
positionTicks: number | null;
isPaused: boolean | null;
positionUpdatedUtc: number | null;
/** The static watch party this live session belongs to, if any - shown
* as a badge on the home page's lobby card. */
staticPartyId: string | null;
staticPartyName: string | null;
}
// --- Static (persistent) watch party ------------------------------------------
/** One member of a static watch party - persisted, independent of any
* live connection (unlike PartyMember). */
export interface StaticPartyMember {
userId: string;
userName: string;
joinedUtc: number;
/** Items this member watched in-app: with the group, in a catch-up
* session, or marked watched by hand. Emby's own Played flag is
* combined with this at read time, never copied into it. */
watchedItemIds: string[];
lastSeenUtc: number;
}
/** The group's own progress through a static watch party's items. */
export interface StaticPartyGroupProgress {
/** Index into `items` of the next item the group will watch. */
nextIndex: number;
/** Where the group stopped inside items[nextIndex] - applied as the
* host's starting position on the next session. */
resumePositionTicks: number;
completedItemIds: string[];
lastSessionUtc: number | null;
}
/** A persistent watch party over a whole series or video playlist - see
* StaticPartyService and spec/static-watch-party.md. */
export interface StaticParty {
id: string;
name: string;
source: { type: 'series' | 'playlist'; sourceId: string };
imageUrl: string;
createdByUserId: string;
createdUtc: number;
items: QueueItem[];
group: StaticPartyGroupProgress;
members: Record<string, StaticPartyMember>;
/** Runtime only (never persisted) - the live group session, if any. */
livePartyId: string | null;
}
/** The persisted shape of a StaticParty - everything but runtime state. */
export type PersistedStaticParty = Omit<StaticParty, 'livePartyId'>;
/** One item of a static watch party, as the client sees it. */
export interface StaticPartyItemDto extends QueueItem {
groupState: 'watched' | 'next' | 'upcoming';
/** Whether the viewer has watched it (in-app or Emby's Played flag). */
viewerWatched: boolean;
}
/** GET /api/static-parties list entry. */
export interface StaticPartySummaryDto {
id: string;
name: string;
sourceType: 'series' | 'playlist';
imageUrl: string;
itemCount: number;
groupWatchedCount: number;
nextItemName: string | null;
memberCount: number;
youAreMember: boolean;
youAreCreator: boolean;
missedCount: number;
livePartyId: string | null;
}
/** GET /api/static-parties/:id - the summary plus every item and member. */
export interface StaticPartyDetailDto extends StaticPartySummaryDto {
items: StaticPartyItemDto[];
missedItemIds: string[];
members: Array<{ userId: string; userName: string; watchedCount: number; isCreator: boolean }>;
}
export interface LeaveResult {

View file

@ -1,234 +0,0 @@
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);
});
});

View file

@ -1,128 +0,0 @@
# Watch party statique (permanente)
## Objectif
Suivre une série entière, ou une playlist vidéo, en groupe sur plusieurs
soirées. L'app mémorise :
- **l'avancement du groupe** : les éléments terminés ensemble, et où la
dernière séance s'est arrêtée ;
- **l'avancement de chaque personne** : ce qu'elle a vu.
Une personne qui revient ou qui rejoint en cours de route voit la liste de
ce qu'elle a **manqué** et peut le rattraper.
Contrairement à une watch party classique, qui vit en mémoire et disparaît
avec son dernier membre, une watch party statique est **persistée sur
disque** et survit aux redémarrages.
## Modèle
| Entité | Où | Rôle |
|---|---|---|
| `StaticParty` | `server/src/types.ts`, persistée | la « saison » du groupe : items, progression du groupe, membres |
| `StaticPartyMember` | dans `StaticParty.members` | `watchedItemIds` = ce que la personne a vu dans l'app |
| Séance | une `Party` live ordinaire + `party.staticLink` | la lecture elle-même : lecteur, sync, flux partagé et lobby réutilisés tels quels |
`party.staticLink.mode` :
- `group` : la séance du groupe. Un item terminé fait avancer le groupe
**et** est crédité à chaque participant connecté.
- `catchUp` : un rattrapage. L'item est crédité aux participants connectés
uniquement, le groupe ne bouge pas.
`party.hiddenFromLobby` : un rattrapage en solo n'apparaît pas dans la liste
des watch parties en cours.
## Règles
- **Item terminé** :
- l'hôte passe à l'item suivant (`next`),
- ou le dernier item de la file se termine (`itemEnded`, nouvel event socket réservé à l'hôte).
Les deux passent par `PartyService.completeCurrentItem`, qui gère aussi le
flag `Played` d'Emby. Un même item n'est jamais compté deux fois de suite
(`lastCompletedItemId`). C'est la même heuristique que
`PlaystateService.markPlayed` : « le groupe est passé à la suite » vaut
« vu ».
- **Seuls les membres connectés** au moment où l'item se termine sont
crédités. Une personne partie en cours d'épisode l'a donc manqué ; elle
peut le marquer comme vu.
- **Manqués d'une personne** : les items terminés par le groupe, dans
l'ordre de la liste, moins ceux qu'elle a vus dans l'app
(`watchedItemIds`), moins ceux qu'Emby a déjà en `Played` pour son compte
(vus ailleurs). C'est la fonction pure `computeMissedIds`.
- **Reprise** : le sweep existant des 20 s enregistre la position de l'hôte
(`group.resumePositionTicks`). La séance suivante démarre avec cette
position comme position de l'hôte, que `Player.ts` utilise déjà pour
placer la première image.
- **Nouveaux épisodes** : chaque lecture de la page, et chaque lancement de
séance, recharge la source. Les nouveaux items sont ajoutés à la fin ; les
items retirés de la source sont conservés dans l'historique
(`mergeItemLists`).
- **Accès** : ouverte à tous les comptes Emby.
- Rejoindre = devenir membre. Rejoindre la séance du groupe depuis le
lobby rend aussi membre de la watch party statique.
- N'importe quel membre peut lancer la séance ; celui qui la lance en
devient l'hôte.
- Quitter efface sa propre progression.
- Seul le créateur peut supprimer la watch party.
- **Contenus** : séries et playlists vidéo uniquement. Une playlist musicale
est refusée.
## API (`server/src/index.ts`)
| Méthode | Route | Rôle |
|---|---|---|
| GET | `/api/static-parties` | liste, avec le nombre de manqués de l'utilisateur et la séance en cours |
| POST | `/api/static-parties` | `{sourceId, name?}` : série, épisode (sa série) ou playlist |
| GET | `/api/static-parties/:id` | détail : items + état groupe / utilisateur, membres, `missedItemIds` |
| POST | `/:id/join`, `/:id/leave` | devenir membre / quitter |
| DELETE | `/:id` | supprimer (créateur uniquement) |
| POST | `/:id/session` | lancer la séance du groupe, ou rejoindre celle en cours, puis renvoyer `{partyId}` |
| POST | `/:id/items/:itemId/watched` | marquer comme vu |
| POST | `/:id/catch-up` | `{itemIds, mode: 'solo'\|'party'}`, renvoie `{partyId}` |
## Persistance
- `StaticPartyStore` écrit un fichier JSON unique :
`${DATA_DIR}/static-parties.json`.
- `DATA_DIR` vaut `./data` par défaut, et `/data` dans l'image Docker.
`docker-compose.yml` y monte `./data`.
- L'écriture est atomique (`.tmp` puis `rename`) et debouncée (2 s).
Elle est flushée de façon synchrone sur `SIGTERM`/`SIGINT`.
- Un fichier corrompu est renommé en `.corrupt` et le serveur démarre à
vide ; il ne crashe jamais pour ça.
- Pas de SQLite : l'image tourne en Node 20 (pas de `node:sqlite`), une
dépendance native serait une première dans l'image, et les données sont
minuscules.
- `livePartyId` n'est jamais persisté : un redémarrage termine toutes les
séances, et la reprise se fait depuis la position enregistrée.
## Client
- `views/staticParty/` (route `/static/:id`) contient :
- l'en-tête, avec la progression du groupe et le bouton lancer / rejoindre
la séance ;
- « Tu as manqué », avec pour chaque élément cases à cocher, *Regarder en
solo* et *Marquer comme vu*, plus deux actions groupées : rattraper en
solo, ou lancer une watch party de rattrapage ;
- les membres ;
- la liste complète des items.
- Bouton « Créer une watch party permanente » sur `views/series` et sur
`views/playlist` (vidéo uniquement).
- Accueil :
- une section « Watch parties permanentes », avec la progression, un badge
« N manqués » et une pastille « séance en cours » ;
- les séances live affichent un lien `↻ nom` vers leur watch party
statique.
## Limites connues
- « Vu » reste une heuristique : passer à la suite compte comme vu, comme
pour le flag `Played` d'Emby. Il n'y a pas de seuil de pourcentage.
- La liste et le détail relisent la source Emby à chaque affichage (une
requête par watch party pour la liste). C'est acceptable à l'échelle d'un
serveur perso ; à mettre en cache si la liste grossit.
- Plusieurs watch parties statiques peuvent exister sur la même série
(des groupes différents) : c'est voulu, sans déduplication.