Add static (persistent) watch parties
A watch party built from a whole series or video playlist that remembers the group's progress and each member's, so someone coming back or joining late sees the items they missed and can catch up (solo, as a catch-up watch party, or by marking items watched). - StaticPartyService + StaticPartyStore: first on-disk state (JSON file under DATA_DIR, atomic debounced writes, flushed on SIGTERM) - each session is an ordinary Party linked via party.staticLink; group position is saved by the 20s sweep and resumed on the next session - PartyService.completeCurrentItem centralizes "item done" (Emby Played + static progress); new host-only "itemEnded" socket event covers the last queued item, which was never marked Played before - missed = group-completed minus watched in-app minus Emby Played - /static/:id view, create buttons on series/playlist pages, home section - Docker volume for /data, spec/static-watch-party.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
04a9116f68
commit
5182def5c9
34 changed files with 1974 additions and 36 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -21,6 +21,9 @@ server/dist/
|
|||
client/dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
## Runtime data (static watch parties - see server/src/services/StaticPartyStore.ts)
|
||||
data/
|
||||
|
||||
## OS cruft
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
src/config.ts env-derived config (EMBY_SERVER_URL, PORT, DATA_DIR)
|
||||
src/types.ts shared interfaces (Party, ItemDto, the
|
||||
Socket.IO event maps, ...)
|
||||
src/Logger.ts party/stream lifecycle logging
|
||||
|
|
@ -245,9 +245,14 @@ build stage, so nothing needs building beforehand:
|
|||
|
||||
```bash
|
||||
docker build -f server/Dockerfile -t watchparty .
|
||||
docker run -d --name watchparty -p 3000:3000 -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty
|
||||
docker run -d --name watchparty -p 3000:3000 -v "$(pwd)/data:/data" -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`.
|
||||
|
|
|
|||
|
|
@ -150,6 +150,9 @@ 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;
|
||||
|
|
@ -230,6 +233,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback
|
|||
next() {
|
||||
socket.emit('next');
|
||||
},
|
||||
itemEnded() {
|
||||
socket.emit('itemEnded');
|
||||
},
|
||||
requestPause() {
|
||||
socket.emit('requestPause');
|
||||
},
|
||||
|
|
|
|||
74
client/src/api/staticParty.ts
Normal file
74
client/src/api/staticParty.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// 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 }) });
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ 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);
|
||||
|
|
@ -20,6 +21,7 @@ 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 +
|
||||
|
|
|
|||
|
|
@ -171,6 +171,42 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -60,3 +60,41 @@
|
|||
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; }
|
||||
|
|
|
|||
|
|
@ -7,4 +7,9 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import { navigate } from '../../router';
|
|||
import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles';
|
||||
import { LOADING_HINT } from '../../loading';
|
||||
import { showToast } from '../../toast';
|
||||
import type { LobbyPartyDto } from '../../types';
|
||||
import { listStaticParties } from '../../api/staticParty';
|
||||
import type { LobbyPartyDto, StaticPartySummaryDto } from '../../types';
|
||||
|
||||
export function renderHome(root: HTMLElement): () => void {
|
||||
root.innerHTML = html;
|
||||
|
|
@ -18,7 +19,20 @@ 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.
|
||||
const lobby = connectLobby((parties) => renderParties(root, parties));
|
||||
// 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 elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000);
|
||||
|
||||
loadRecentSections(root);
|
||||
|
|
@ -58,6 +72,7 @@ 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"
|
||||
|
|
@ -93,6 +108,39 @@ 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
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -691,7 +691,10 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
|||
video.addEventListener('play', onDriverStateChanged);
|
||||
video.addEventListener('pause', onDriverStateChanged);
|
||||
video.addEventListener('seeked', onDriverStateChanged);
|
||||
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
|
||||
// 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(); });
|
||||
if (!heartbeat) heartbeat = window.setInterval(reportHeartbeat, 3000);
|
||||
|
||||
// Burn-in (image) subtitle options are host-only (see
|
||||
|
|
|
|||
|
|
@ -25,8 +25,9 @@
|
|||
|
||||
.playlistActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 12px 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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';
|
||||
|
|
@ -56,6 +57,15 @@ 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) : '';
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@
|
|||
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ 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';
|
||||
|
|
@ -34,6 +35,7 @@ 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>';
|
||||
|
|
|
|||
138
client/src/views/staticParty/StaticParty.css
Normal file
138
client/src/views/staticParty/StaticParty.css
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/* ---- 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; }
|
||||
}
|
||||
39
client/src/views/staticParty/StaticParty.html
Normal file
39
client/src/views/staticParty/StaticParty.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<button class="linklike" id="back">← 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>
|
||||
219
client/src/views/staticParty/StaticParty.ts
Normal file
219
client/src/views/staticParty/StaticParty.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
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;
|
||||
});
|
||||
}
|
||||
28
client/src/views/staticParty/createStaticButton.ts
Normal file
28
client/src/views/staticParty/createStaticButton.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -8,3 +8,9 @@ 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
|
||||
|
|
|
|||
|
|
@ -4562,6 +4562,34 @@ 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
|
||||
|
|
@ -4578,7 +4606,10 @@ would have overflowed by well over 200px. A separate portrait check
|
|||
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: server restart or long idle time ends every party.
|
||||
- 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 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
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ 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"]
|
||||
|
|
|
|||
|
|
@ -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/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/staticPartyService.test.ts test/transcodeService.test.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
|
|||
|
|
@ -83,4 +83,11 @@ 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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,3 +17,11 @@ 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';
|
||||
|
|
|
|||
|
|
@ -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 { EMBY_SERVER_URL, PORT } from './config';
|
||||
import { DATA_DIR, 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,8 +16,13 @@ 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 } from './types';
|
||||
import type { ClientToServerEvents, EmbyItem, EmbySession, ItemDto, MediaType, MusicTrackInput, QueueItem, ServerToClientEvents, StaticParty } from './types';
|
||||
|
||||
StaticPartyStore.configure(DATA_DIR);
|
||||
StaticPartyService.init();
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
|
||||
|
|
@ -369,11 +374,7 @@ 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((e) => ({
|
||||
itemId: e.Id,
|
||||
itemName: formatEpisodeName(e),
|
||||
itemImageUrl: `/api/catalog/image/${e.Id}`
|
||||
}));
|
||||
queue = remaining.map(toVideoQueueItem);
|
||||
}
|
||||
|
||||
const party = PartyService.create(
|
||||
|
|
@ -410,6 +411,17 @@ 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
|
||||
|
|
@ -456,11 +468,7 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
|
|||
return;
|
||||
}
|
||||
|
||||
const queue: QueueItem[] = items.map((e) => ({
|
||||
itemId: e.Id,
|
||||
itemName: e.Type === 'Episode' ? formatEpisodeName(e) : e.Name,
|
||||
itemImageUrl: `/api/catalog/image/${e.Id}`
|
||||
}));
|
||||
const queue: QueueItem[] = items.map(toVideoQueueItem);
|
||||
const party = PartyService.create(
|
||||
session.userId, session.userName,
|
||||
queue[0]!.itemId, queue[0]!.itemName, queue[0]!.itemImageUrl,
|
||||
|
|
@ -608,6 +616,12 @@ 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));
|
||||
});
|
||||
|
|
@ -662,6 +676,213 @@ 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
|
||||
|
|
@ -730,6 +951,9 @@ 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);
|
||||
|
|
@ -766,6 +990,16 @@ 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'})`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
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';
|
||||
|
||||
/**
|
||||
|
|
@ -35,6 +36,9 @@ 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,
|
||||
|
|
@ -207,6 +211,17 @@ 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
|
||||
|
|
@ -410,8 +425,11 @@ class SocketGatewayClass {
|
|||
}
|
||||
|
||||
private lobbySnapshot(): LobbyPartyDto[] {
|
||||
return PartyService.listActive().map((party) => {
|
||||
// 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) => {
|
||||
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
|
||||
|
|
@ -429,7 +447,9 @@ 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
|
||||
positionUpdatedUtc: isVideo && host ? host.lastUpdatedUtc : null,
|
||||
staticPartyId: staticParty ? staticParty.id : null,
|
||||
staticPartyName: staticParty ? staticParty.name : null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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';
|
||||
|
||||
/**
|
||||
|
|
@ -209,6 +210,7 @@ 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)!;
|
||||
|
|
@ -230,7 +232,9 @@ class PartyServiceClass {
|
|||
if (party.queueIndex >= party.queue.length - 1) {
|
||||
return false;
|
||||
}
|
||||
const outgoingItemId = party.itemId; // whatever was just playing - see the markPlayed loop below
|
||||
// Whatever was just playing is "done" - before the party's own state
|
||||
// moves on. See completeCurrentItem's own comment.
|
||||
this.completeCurrentItem(party);
|
||||
party.queueIndex += 1;
|
||||
const next = party.queue[party.queueIndex]!;
|
||||
party.itemId = next.itemId;
|
||||
|
|
@ -241,18 +245,6 @@ 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,
|
||||
|
|
@ -277,6 +269,40 @@ 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());
|
||||
|
|
@ -290,6 +316,7 @@ 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
|
||||
|
|
|
|||
316
server/src/services/StaticPartyService.ts
Normal file
316
server/src/services/StaticPartyService.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
'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();
|
||||
146
server/src/services/StaticPartyStore.ts
Normal file
146
server/src/services/StaticPartyStore.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
'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();
|
||||
|
|
@ -29,6 +29,11 @@ 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
|
||||
|
|
@ -180,6 +185,11 @@ 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,
|
||||
|
|
@ -359,6 +369,25 @@ 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 {
|
||||
|
|
@ -415,6 +444,85 @@ 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 {
|
||||
|
|
|
|||
234
server/test/staticPartyService.test.ts
Normal file
234
server/test/staticPartyService.test.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
128
spec/static-watch-party.md
Normal file
128
spec/static-watch-party.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# 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.
|
||||
Loading…
Reference in a new issue