Compare commits

..

No commits in common. "master" and "feat/static-watch-party" have entirely different histories.

30 changed files with 288 additions and 852 deletions

View file

@ -55,16 +55,13 @@ jobs:
# node:20-alpine n'a pas git préinstallé - même approche de clone
# manuel que le job publish (pas d'actions/checkout, action JS qui a
# ses propres soucis dans les conteneurs Alpine minimalistes ici).
# Sur une PR, `ref_name` vaut le numéro de la PR (ex. `29`), qui n'est
# pas une ref récupérable ("couldn't find remote ref 29") : on récupère
# alors la branche source de la PR (`head_ref`, vide hors PR).
- name: Récupérer les sources
run: |
set -eu
apk add --no-cache git
git init -q
git remote add origin "${{ github.server_url }}/${{ github.repository }}.git"
git fetch -q --depth 1 origin "${{ github.head_ref || github.ref_name }}"
git fetch -q --depth 1 origin "${{ github.ref_name }}"
git checkout -q FETCH_HEAD
- name: Serveur - installer les dépendances

View file

@ -11,7 +11,6 @@ import { renderAlbum } from './views/album/Album';
import { renderPlaylist } from './views/playlist/Playlist';
import { renderPartyPlayer } from './views/player/Player';
import { renderStaticParty } from './views/staticParty/StaticParty';
import { renderStaticParties } from './views/staticParties/StaticParties';
route('/', () => navigate('/home'));
route('/login', renderLogin);
@ -22,7 +21,6 @@ route('/artist/:id', renderArtist);
route('/album/:id', renderAlbum);
route('/playlist/:id', renderPlaylist);
route('/party/:id', renderPartyPlayer);
route('/static', renderStaticParties);
route('/static/:id', renderStaticParty);
/**

View file

@ -3,7 +3,6 @@
<div class="sidebarBrand">Watch Party</div>
<nav class="sidebarNav" id="sidebarNav">
<a href="#/home" class="navItem">Accueil</a>
<a href="#/static" class="navItem">Lounge parties</a>
<div class="navSectionLabel">Bibliothèques</div>
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
</nav>
@ -42,7 +41,6 @@
plain scrim tap-only close used to leave unsolved). -->
<div class="sheetGrabberHandle" id="sheetGrabberHandle"><div class="sheetGrabber"></div></div>
<a href="#/home" class="navItem sheetPrimary"><span class="navIcon" data-icon="home"></span>Accueil</a>
<a href="#/static" class="navItem sheetPrimary"><span class="navIcon" data-icon="static"></span>Lounge parties</a>
<div class="navSectionLabel">Bibliothèques</div>
<div class="sheetGrid" id="mobileLibGrid"></div>
<div id="mobilePlaylistsSlot"></div>

View file

@ -18,8 +18,6 @@ const SHOWS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18"
const MUSIC_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="8" cy="17" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10.5 17V5.5l7-1.5v10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="17.5" cy="14" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>';
const MUSICVIDEOS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10 9.5 15.5 12 10 14.5Z" fill="currentColor"/></svg>';
const PLAYLISTS_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="4.5" cy="7" r="1.3" fill="currentColor"/><circle cx="4.5" cy="12" r="1.3" fill="currentColor"/><circle cx="4.5" cy="17" r="1.3" fill="currentColor"/><line x1="9" y1="7" x2="20" y2="7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="17" x2="20" y2="17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
// The "Lounge parties" tab (static watch parties) - a circular "resume" arrow.
const STATIC_PARTIES_ICON_SVG = '<svg viewBox="0 0 24 24"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M19.5 4v4h-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 9l5 3-5 3Z" fill="currentColor"/></svg>';
// Emby could in principle add a CollectionType this app doesn't map yet
// (server/src/services/CatalogService.ts's VISIBLE_COLLECTION_TYPES
// would need updating first either way) - falls back to a plain folder
@ -55,7 +53,6 @@ export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
appEl.innerHTML = html;
appEl.querySelector('[data-icon="home"]')!.innerHTML = HOME_ICON_SVG;
appEl.querySelector('[data-icon="static"]')!.innerHTML = STATIC_PARTIES_ICON_SVG;
// Mounted once, outside #mainContent, so a listen party survives
// navigating the router - see musicBar/MusicBar.ts's header comment.
@ -101,11 +98,8 @@ export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
const highlight = () => {
appEl.querySelectorAll('.navItem').forEach((a) => {
const href = a.getAttribute('href');
const isHome = href === '#/home';
// The static-parties tab stays highlighted on one party's own page too.
const isStaticSection = href === '#/static' && location.hash.startsWith('#/static/');
a.classList.toggle('active', href === location.hash || (isHome && !location.hash) || isStaticSection);
const isHome = a.getAttribute('href') === '#/home';
a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash));
});
};
highlight();

View file

@ -61,9 +61,7 @@ const DRILL_DOWN: Record<string, { route: string; label: string }> = {
function posterTileHtml(item: ItemDto): string {
const drill = item.type ? DRILL_DOWN[item.type] : undefined;
const drillHref = drill ? `#/${drill.route}/${item.id}` : null;
// Video -> "watch party", audio -> "listen party" - see the party kinds
// in docs/architecture.md (the third, "lounge party", is the static one).
const partyLabel = item.type === 'Audio' ? 'Lancer une listen party' : 'Lancer une watch party';
const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party';
return `
<div class="tile">
${drillHref ? `<a href="${drillHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''}
@ -118,7 +116,7 @@ export function tileHtml(item: ItemDto): string {
}
/**
* Wires up every "Lancer une watch/listen party" button inside `container`, now
* Wires up every "Lancer une watch party" button inside `container`, now
* and for any tile added to it later. `onStart(itemId)` does the actual
* work (create the party + navigate) - kept as a caller-supplied callback
* so this module doesn't need to depend on api/party.ts/router.ts itself.
@ -134,7 +132,6 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.startPartyBtn');
if (!btn || btn.disabled) return;
e.preventDefault();
const idleLabel = btn.textContent; // "watch party" or "listen party" - put back as-is on failure
btn.disabled = true;
btn.textContent = 'Démarrage...';
try {
@ -142,7 +139,7 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
} catch (err) {
showToast((err as Error).message, { variant: 'error' });
btn.disabled = false;
btn.textContent = idleLabel;
btn.textContent = 'Lancer une watch party';
}
});
}

View file

@ -181,7 +181,7 @@ export interface LobbyPartyDto {
export interface StaticPartySummaryDto {
id: string;
name: string;
sourceType: 'series' | 'season' | 'playlist';
sourceType: 'series' | 'playlist';
imageUrl: string;
itemCount: number;
groupWatchedCount: number;

View file

@ -69,7 +69,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
// states) - the label lives in aria-label/title for accessibility
// rather than as visible text, now that there's one per track instead
// of one per page (see docs/architecture.md's "Music UI rework").
const startLabel = 'Lancer une listen party';
const startLabel = 'Écouter ensemble';
const addLabel = 'Ajouter à la file';
const startAlbumLabel = 'Écouter cet album';
const addAlbumLabel = "Ajouter l'album à la file";

View file

@ -61,17 +61,40 @@
font-weight: 600;
}
/* Which kind of party a card is - "Watch party" (video) or "Listen party"
(audio). Lounge parties (static) have their own tab, never this list. */
.partyKind {
align-self: flex-start;
font-size: 10px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
padding: 1px 6px;
border-radius: 4px;
/* ---- 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;
}
.partyKind--video { color: var(--accent-bright); background: rgba(82, 181, 75, .15); }
.partyKind--music { color: #7fb4ff; background: rgba(127, 180, 255, .15); }
.staticCard:hover { background: #2b252e; }
.staticCardLive {
color: var(--accent-bright);
font-size: 12px;
font-weight: 600;
}
.staticCardMissed {
flex-shrink: 0;
padding: 4px 10px;
border-radius: 12px;
background: var(--danger);
color: #fff;
font-size: 12px;
font-weight: 600;
}
/* A live session's link back to the static watch party it belongs to. */
.partyCardStatic {
color: var(--accent-bright);
font-size: 12px;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.partyCardStatic:hover { text-decoration: underline; }

View file

@ -3,8 +3,13 @@
</div>
<section class="section" id="partiesSection" hidden>
<h2>Parties en cours</h2>
<h2>Watch parties en cours</h2>
<div id="partiesCarousel" class="carousel"></div>
</section>
<section class="section" id="staticSection" hidden>
<h2>Watch parties permanentes</h2>
<div id="staticCarousel" class="carousel"></div>
</section>
<div id="recentSections"></div>

View file

@ -7,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,9 +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.
// A static watch party's sessions live in their own tab
// (views/staticParties), not here - this list is classic parties only.
const lobby = connectLobby((parties) => renderParties(root, parties.filter((p) => !p.staticPartyId)));
// 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);
@ -59,8 +71,8 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
<div class="partyCard">
<img class="${p.mediaType === 'music' ? 'partyCardArt--square' : ''}" src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
<div class="partyCardInfo">
<span class="partyKind partyKind--${p.mediaType}">${p.mediaType === 'music' ? 'Listen party' : 'Watch party'}</span>
<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"
@ -96,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
/**

View file

@ -6,7 +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 lounge party</button>
<button class="hide" id="createStaticBtn">Créer une watch party permanente</button>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</div>

View file

@ -63,7 +63,7 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr
if (items[0]!.type !== 'Audio') {
const createStaticBtn = root.querySelector('#createStaticBtn') as HTMLButtonElement;
createStaticBtn.classList.remove('hide');
wireCreateStaticButton(createStaticBtn, () => playlistId);
wireCreateStaticButton(createStaticBtn, playlistId);
}
listEl.innerHTML = items.map((item, i) => {

View file

@ -17,8 +17,7 @@
.seriesPoster.noImage { visibility: hidden; }
.seriesActions,
.seasonActions {
.seriesActions {
display: flex;
flex-wrap: wrap;
align-items: center;
@ -26,11 +25,6 @@
margin-top: 8px;
}
/* The selected season's own launch buttons, between the tabs and its
episodes. */
.seasonActions { margin: 0 0 16px; }
.seasonActions button { padding: 8px 14px; font-size: 13px; }
@media (max-width: 600px) {
.seriesHeader { flex-direction: column; }
.seriesPoster { width: 120px; }

View file

@ -5,15 +5,10 @@
<h1 id="seriesTitle"></h1>
<p class="seriesOverview hide" id="seriesOverview"></p>
<div class="seriesActions">
<button id="launchSeriesBtn">Lancer toute la série</button>
<button class="secondaryBtn" id="createStaticBtn">Créer une lounge party</button>
<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 class="seasonActions hide" id="seasonActions">
<button id="launchSeasonBtn">Lancer cette saison</button>
<button class="secondaryBtn" id="createSeasonStaticBtn">Lounge party de cette saison</button>
</div>
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>

View file

@ -35,26 +35,13 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom
overviewEl.classList.remove('hide');
}
(root.querySelector('#embyLink') as HTMLAnchorElement).href = series.embyUrl;
// Whole series: a watch party over every season from S1E1, or a lounge
// party over the same (see the server's seriesQueueFrom).
wireLaunchButton(root.querySelector('#launchSeriesBtn') as HTMLButtonElement, () => seriesId);
wireCreateStaticButton(root.querySelector('#createStaticBtn') as HTMLButtonElement, () => seriesId);
wireCreateStaticButton(root.querySelector('#createStaticBtn') as HTMLButtonElement, seriesId);
if (!seasons.length) {
(root.querySelector('#launchSeriesBtn') as HTMLButtonElement).disabled = true;
(root.querySelector('#createStaticBtn') as HTMLButtonElement).disabled = true;
root.querySelector('#episodeList')!.innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
return;
}
// Just the selected season - both buttons read whichever tab is active
// at click time, so they're wired once rather than per tab switch.
let selectedSeasonId = seasons[0]!.id;
root.querySelector('#seasonActions')!.classList.remove('hide');
wireLaunchButton(root.querySelector('#launchSeasonBtn') as HTMLButtonElement, () => selectedSeasonId);
wireCreateStaticButton(root.querySelector('#createSeasonStaticBtn') as HTMLButtonElement, () => selectedSeasonId);
const tabsEl = root.querySelector('#seasonTabs')!;
tabsEl.innerHTML = seasons
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`)
@ -65,35 +52,13 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom
if (tab.classList.contains('active')) return;
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
tab.classList.add('active');
selectedSeasonId = tab.dataset.seasonId as string;
loadEpisodes(root, seriesId, selectedSeasonId);
loadEpisodes(root, seriesId, tab.dataset.seasonId as string);
});
});
loadEpisodes(root, seriesId, seasons[0]!.id);
}
/**
* A button that launches a watch party over whatever `itemId()` returns
* at click time - the series (every season) or the selected season (see
* the server's POST /api/parties Series/Season branches) - then opens it.
*/
function wireLaunchButton(btn: HTMLButtonElement, itemId: () => string): void {
const idleLabel = btn.textContent || '';
btn.addEventListener('click', () => {
btn.disabled = true;
btn.textContent = 'Démarrage...';
createParty(itemId())
.then((party) => navigate(`/party/${party.partyId}`))
.catch((err: unknown) => {
console.error('[series] launch failed', err);
showToast((err as Error).message, { variant: 'error' });
btn.disabled = false;
btn.textContent = idleLabel;
});
});
}
async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise<void> {
const listEl = root.querySelector('#episodeList')!;
listEl.innerHTML = LOADING_HINT;

View file

@ -1,81 +0,0 @@
/* ---- "Lounge parties" tab: one card per static party ---- */
.staticGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
}
/* The whole card is the link to /static/:id. */
.staticCard {
display: flex;
align-items: center;
gap: 14px;
background: var(--bg-header);
border-radius: 10px;
padding: 12px;
color: inherit;
text-decoration: none;
}
.staticCard:hover { background: #2b252e; }
.staticCard img {
width: 60px;
height: 90px;
object-fit: cover;
border-radius: 6px;
background: var(--panel);
flex-shrink: 0;
}
.staticCard img.noImage { visibility: hidden; }
.staticCardInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.staticCardInfo strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.staticCardInfo .hint { margin: 0; }
.staticCardNext {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.staticCardBar {
height: 4px;
border-radius: 2px;
background: var(--panel);
overflow: hidden;
}
.staticCardBar > div { height: 100%; background: var(--accent); }
.staticCardLive {
color: var(--accent-bright);
font-size: 12px;
font-weight: 600;
}
.staticCardMissed {
flex-shrink: 0;
align-self: flex-start;
padding: 4px 10px;
border-radius: 12px;
background: var(--danger);
color: #fff;
font-size: 12px;
font-weight: 600;
}
@media (max-width: 400px) {
.staticGrid { grid-template-columns: 1fr; }
}

View file

@ -1,9 +0,0 @@
<div class="pageHeader">
<h1>Lounge parties</h1>
</div>
<p class="hint">
Une série ou une playlist regardée en groupe sur plusieurs séances : la progression du groupe
et celle de chacun sont gardées en mémoire. Pour en créer une, ouvre une série ou une playlist
vidéo et choisis « Créer une lounge party ».
</p>
<div class="staticGrid" id="staticGrid"></div>

View file

@ -1,77 +0,0 @@
import './StaticParties.css';
import html from './StaticParties.html';
import { connectLobby } from '../../api/party';
import { listStaticParties } from '../../api/staticParty';
import { escapeHtml } from '../../tiles';
import { SOURCE_LABELS } from '../staticParty/StaticParty';
import { LOADING_HINT } from '../../loading';
import type { StaticPartySummaryDto } from '../../types';
/**
* Route `/static` - the "Lounge parties" tab: every static
* watch party, kept apart from the home page's classic live parties
* (whose lobby list leaves static sessions out - see Home.ts).
*
* A REST list, re-fetched whenever the set of live static-party sessions
* in the lobby changes (a session starting or ending is what moves a
* card's "séance en cours"/progress state). Not on every lobby snapshot:
* those also fire on every pause/join, and each list fetch reads Emby
* once per static party (see server/src/index.ts's syncStaticSource).
*/
export function renderStaticParties(root: HTMLElement): () => void {
root.innerHTML = html;
const grid = root.querySelector('#staticGrid') as HTMLElement;
grid.innerHTML = LOADING_HINT;
let sessionsKey: string | null = null;
const lobby = connectLobby((parties) => {
const key = parties.map((p) => p.staticPartyId).filter(Boolean).sort().join(',');
if (key === sessionsKey) return;
sessionsKey = key;
load(grid);
});
// Same leak-prevention contract as Home.ts: the router calls this before
// rendering the next view.
return function cleanup() {
lobby.disconnect();
};
}
/** Fetches and renders every static party into `grid`. */
function load(grid: HTMLElement): void {
listStaticParties()
.then((parties) => {
if (!grid.isConnected) return; // navigated away mid-flight
render(grid, parties);
})
.catch((err: unknown) => {
console.error('[staticParties] list failed to load', err);
if (grid.isConnected) grid.innerHTML = `<p class="error">${escapeHtml((err as Error).message)}</p>`;
});
}
/** One card per static party: progress, next item, the viewer's missed
* count, and whether a session is running right now. */
function render(grid: HTMLElement, parties: StaticPartySummaryDto[]): void {
if (!parties.length) {
grid.innerHTML = '<p class="hint">Aucune lounge party pour le moment.</p>';
return;
}
grid.innerHTML = parties.map((p) => {
const percent = p.itemCount ? Math.round((p.groupWatchedCount / p.itemCount) * 100) : 0;
return `
<a class="staticCard" href="#/static/${escapeHtml(p.id)}">
<img src="${escapeHtml(p.imageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="staticCardInfo">
<strong>${escapeHtml(p.name)}</strong>
<span class="hint">${SOURCE_LABELS[p.sourceType]} · ${p.groupWatchedCount}/${p.itemCount} vus · ${p.memberCount} membre${p.memberCount > 1 ? 's' : ''}</span>
<div class="staticCardBar"><div style="width:${percent}%"></div></div>
${p.livePartyId
? '<span class="staticCardLive">● Séance en cours</span>'
: `<span class="staticCardNext">${p.nextItemName ? `Prochain : ${escapeHtml(p.nextItemName)}` : 'Le groupe a tout vu.'}</span>`}
</div>
${p.missedCount ? `<span class="staticCardMissed" title="Éléments manqués">${p.missedCount} manqué${p.missedCount > 1 ? 's' : ''}</span>` : ''}
</a>`;
}).join('');
}

View file

@ -2,14 +2,14 @@
<div class="staticHeader">
<img class="staticCover" id="staticCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo">
<span class="staticBadge">Lounge party</span>
<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 lounge party</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>

View file

@ -16,14 +16,6 @@ import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
import type { StaticPartyDetailDto, StaticPartyItemDto } from '../../types';
/** How each lounge party source is named in the UI - shared with the
* "Lounge parties" tab (views/staticParties). */
export const SOURCE_LABELS: Record<StaticPartyDetailDto['sourceType'], string> = {
series: 'Série',
season: 'Saison',
playlist: 'Playlist'
};
/**
* Route `/static/:id` - one static (persistent) watch party: the group's
* progress through the whole series/playlist, its members, and - the
@ -42,7 +34,7 @@ export async function renderStaticParty(root: HTMLElement, params: RouteParams):
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 lounge party.')}</p>`;
root.innerHTML = `<p class="error">${escapeHtml((err as Error).message || 'Impossible de charger cette watch party.')}</p>`;
return;
}
@ -69,7 +61,7 @@ async function reload(root: HTMLElement, staticId: string): Promise<void> {
function render(root: HTMLElement, detail: StaticPartyDetailDto): void {
(root.querySelector('#staticCover') as HTMLImageElement).src = detail.imageUrl;
root.querySelector('#staticTitle')!.textContent = detail.name;
const sourceLabel = SOURCE_LABELS[detail.sourceType];
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;
@ -152,14 +144,14 @@ function wireActions(root: HTMLElement, staticId: string): void {
joinBtn.addEventListener('click', () => {
runBusy(joinBtn, async () => {
await joinStaticParty(staticId);
showToast('Tu fais maintenant partie de cette lounge party.');
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 lounge party ? Ta progression y sera oubliée.')) return;
if (!confirm('Quitter cette watch party ? Ta progression y sera oubliée.')) return;
runBusy(leaveBtn, async () => {
await leaveStaticParty(staticId);
await reload(root, staticId);
@ -168,11 +160,11 @@ function wireActions(root: HTMLElement, staticId: string): void {
const deleteBtn = root.querySelector('#deleteBtn') as HTMLButtonElement;
deleteBtn.addEventListener('click', () => {
if (!confirm('Supprimer cette lounge party pour tout le monde ? La progression de chacun sera perdue.')) return;
if (!confirm('Supprimer cette watch party pour tout le monde ? La progression de chacun sera perdue.')) return;
runBusy(deleteBtn, async () => {
await deleteStaticParty(staticId);
showToast('Lounge party supprimée.');
navigate('/static');
showToast('Watch party supprimée.');
navigate('/home');
});
});

View file

@ -3,17 +3,14 @@ import { navigate } from '../../router';
import { showToast } from '../../toast';
/**
* "Créer une lounge party" - shared by views/series (whole series, or
* the selected season) and views/playlist: creates a lounge party over
* the source id (a series, a season or a video playlist - the server
* decides which, and refuses anything else with a message) and opens its
* page. `sourceId` is read at click time, so a caller whose source can
* change (the selected season tab) wires the button only once.
* "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, getSourceId: () => string): void {
export function wireCreateStaticButton(btn: HTMLButtonElement, sourceId: string): void {
const idleLabel = btn.textContent || '';
btn.addEventListener('click', () => {
const sourceId = getSourceId();
btn.disabled = true;
btn.textContent = 'Création...';
createStaticParty(sourceId)

View file

@ -851,23 +851,6 @@ player against the new `itemId` on that message. A movie or a
standalone-started episode just gets a one-item queue, so this is a strict
superset of the old single-item behavior, not a separate code path.
**Later: a whole series or one season, not just "from this episode".**
`POST /api/parties` also accepts a `Series` id and a `Season` id, not
only an episode.
- **Series**: queues every season from S1E1. Specials (season 0) sort
first in Emby's list and are left out, so launching the whole series
builds the same queue as clicking S1E1. This is the pure
`CatalogService.seriesQueueFrom`, which the episode path now uses too.
- **Season**: queues only that season's episodes
(`CatalogService.seasonQueue`).
- **Where it's launched from**: the series page's "Lancer toute la
série" / "Lancer cette saison" buttons. A series tile in a library
also gets the normal "Lancer une watch party" button: `Series` joined
`WATCH_PARTY_TYPES`.
- **Lounge parties** take the same two sources (a whole series or one
season). See spec/static-watch-party.md.
### Player: language, subtitles, quality
`GET /api/catalog/item/:id/playback-options` reads `PlaybackInfo`'s
@ -4593,19 +4576,10 @@ short version:
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.** One JSON file per static party
(`StaticPartyStore`, `${DATA_DIR}/static-parties/<id>.json`). Writes
are atomic, debounced per party, and flushed on SIGTERM. It started
as a single file for every party; that was split, so a write only
touches the party that changed and a corrupt file costs only that
party. The old single file is migrated once, on first load. "Party
lifecycle" above still holds for ordinary parties.
- **Its own tab and its own name.** The UI calls these **Lounge
parties**, next to **Watch party** (classic video) and **Listen
party** (classic audio). The code keeps "static party". The "Lounge
parties" tab (`/static`) lists them. The home page's live "Parties en
cours" list (each card badged Watch/Listen party) leaves their
sessions out, so classic and lounge parties never share a list.
- **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

View file

@ -8,7 +8,7 @@ import { Server as SocketIoServer } from 'socket.io';
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, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from './services/CatalogService';
import { CatalogService, CatalogFetchError, imageUrlFor, splitPlaylistByMediaType } from './services/CatalogService';
import { PartyService } from './services/PartyService';
import { MusicQueueService } from './services/MusicQueueService';
import { TranscodeService } from './services/TranscodeService';
@ -258,7 +258,7 @@ app.get('/api/catalog/party-stream/:partyId/master.m3u8', allowCastCors, asyncRo
if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
await PartyStreamService.serveMasterPlaylist(req, res, party, session);
@ -272,7 +272,7 @@ app.get('/api/catalog/party-stream/:partyId/raw', allowCastCors, asyncRoute(asyn
if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
await PartyStreamService.serveRawAsset(req, res, party, session);
@ -302,7 +302,7 @@ app.get('/api/catalog/burn/:partyId/master.m3u8', allowCastCors, asyncRoute(asyn
if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
if (!party.burnInSubtitleIndex) {
@ -318,7 +318,7 @@ app.get('/api/catalog/burn/:partyId/:segment', allowCastCors, asyncRoute(async (
if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
await TranscodeService.serveSegment(req, res, party, param(req, 'segment'));
@ -356,20 +356,6 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
// resolveMusicTracksForItem). Asked for directly, alongside the same
// action on the party's own existing queue - see the "queue" route
// below - both share this resolution.
// A whole series - every season, in order, from S1E1 (see
// seriesQueueFrom). Launched from the series page's own button or its
// library tile; an episode's own button still starts from that episode
// (the Episode branch below).
if (item.Type === 'Series') {
await createPartyFromSeries(res, session, item);
return;
}
// Just one season - the series page's per-season button (see seasonQueue).
if (item.Type === 'Season') {
await createPartyFromSeason(res, session, item);
return;
}
if (item.Type === 'MusicAlbum' || item.Type === 'MusicArtist') {
const tracks = await resolveMusicTracksForItem(session, item);
createMusicPartyFromTracks(res, session, tracks);
@ -386,8 +372,9 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
let queue: QueueItem[] | null = null;
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId);
const remaining = seriesQueueFrom(all, itemId);
queue = (remaining.length ? remaining : [item]).map(toVideoQueueItem);
const startIndex = all.findIndex((e) => e.Id === itemId);
const remaining = startIndex >= 0 ? all.slice(startIndex) : [item];
queue = remaining.map(toVideoQueueItem);
}
const party = PartyService.create(
@ -492,45 +479,6 @@ async function createPartyFromPlaylist(res: Response, session: EmbySession, play
res.json(PartyService.toPublicState(party, session.userId));
}
/** The "launch the whole series" half of POST /api/parties - a watch
* party queued with every episode of every season (see seriesQueueFrom). */
async function createPartyFromSeries(res: Response, session: EmbySession, series: EmbyItem): Promise<void> {
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, series.Id);
const queue = seriesQueueFrom(episodes).map(toVideoQueueItem);
if (!queue.length) {
res.status(400).json({ error: "Cette série n'a aucun épisode." });
return;
}
createVideoPartyFromQueue(res, session, queue, `series="${series.Name}"`);
}
/** The "launch just this season" half of POST /api/parties - only that
* season's episodes, nothing after it (see seasonQueue). */
async function createPartyFromSeason(res: Response, session: EmbySession, season: EmbyItem): Promise<void> {
if (!season.SeriesId) {
res.status(400).json({ error: 'Saison introuvable.' });
return;
}
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, season.SeriesId);
const queue = seasonQueue(episodes, season).map(toVideoQueueItem);
if (!queue.length) {
res.status(400).json({ error: "Cette saison n'a aucun épisode." });
return;
}
createVideoPartyFromQueue(res, session, queue, `season="${season.Name}" series=${season.SeriesId}`);
}
/** Creates a video watch party playing `queue` in order, hosted by
* `session` - shared by the whole-series and single-season launches. */
function createVideoPartyFromQueue(res: Response, session: EmbySession, queue: QueueItem[], logContext: string): void {
const first = queue[0]!;
const party = PartyService.create(session.userId, session.userName, first.itemId, first.itemName, first.itemImageUrl, queue, 'video');
PartyService.setMemberEmbyToken(party, session.userId, session.embyToken);
Logger.party(`queue launch partyId=${party.id} ${logContext} episodes=${queue.length}`);
SocketGateway.broadcastLobby(io);
res.json(PartyService.toPublicState(party, session.userId));
}
/** A raw Emby item known/expected to be music-shaped (Audio, MusicAlbum,
* or MusicArtist - the three POST /api/parties and the "queue" route
* below ever call this for) resolved into a flat, ordered track list -
@ -608,12 +556,12 @@ function createMusicPartyFromTracks(res: Response, session: EmbySession, tracks:
app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Listen party introuvable.' });
res.status(404).json({ error: 'Watch party musique introuvable.' });
return;
}
const session = req.embySession!;
if (!party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
const track = req.body || {};
@ -639,12 +587,12 @@ app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
app.post('/api/parties/:partyId/queue', requireSession, asyncRoute(async (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Listen party introuvable.' });
res.status(404).json({ error: 'Watch party musique introuvable.' });
return;
}
const session = req.embySession!;
if (!party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
const itemId = req.body?.itemId;
@ -714,7 +662,7 @@ app.post('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId'));
const session = req.embySession!;
if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette party." });
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
return;
}
res.json({ token: CastTokenService.issue(party.id, session) });
@ -734,18 +682,12 @@ app.delete('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
// 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 under `session`'s own account - so
* each item carries *their* Emby UserData (Played): the whole series
* from S1E1 (specials left out - see seriesQueueFrom), one season's
* episodes, or a playlist's video items. */
/** 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 seriesQueueFrom(await CatalogService.getAllSeriesEpisodesRaw(session, source.sourceId));
}
if (source.type === 'season') {
if (!source.seriesId) return [];
const episodes = await CatalogService.getAllSeriesEpisodesRaw(session, source.seriesId);
return seasonQueue(episodes, { Id: source.sourceId, IndexNumber: source.seasonNumber });
return CatalogService.getAllSeriesEpisodesRaw(session, source.sourceId);
}
const { mediaType, items } = splitPlaylistByMediaType(await CatalogService.getPlaylistItemsRaw(session, source.sourceId));
return mediaType === 'video' ? items : [];
@ -779,7 +721,7 @@ function liveGroupPartyId(sp: StaticParty): string | null {
/** 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: 'Lounge party introuvable.' });
if (!sp) res.status(404).json({ error: 'Watch party permanente introuvable.' });
return sp;
}
@ -801,38 +743,28 @@ app.post('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
const session = req.embySession!;
const item = await CatalogService.getRawItem(session, sourceId);
// A whole series, one season of it, or a video playlist. An episode
// stands for its whole series - the natural entry point from
// An episode stands for its whole series - the natural entry point from
// views/series is the series itself, but nothing's lost accepting one.
// `sourceItem` is what gives the default name and the artwork: the
// series itself for a season (a season's own poster is often missing).
let source: StaticParty['source'];
let sourceItem = item;
let defaultName = item.Name;
if (item.Type === 'Series') {
source = { type: 'series', sourceId: item.Id };
} else if (item.Type === 'Season' && item.SeriesId) {
source = { type: 'season', sourceId: item.Id, seriesId: item.SeriesId, seasonNumber: item.IndexNumber };
sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
defaultName = `${sourceItem.Name} - ${item.Name}`;
} else if (item.Type === 'Episode' && item.SeriesId) {
source = { type: 'series', sourceId: item.SeriesId };
sourceItem = await CatalogService.getRawItem(session, item.SeriesId);
defaultName = sourceItem.Name;
} else if (item.Type === 'Playlist') {
source = { type: 'playlist', sourceId: item.Id };
} else {
res.status(400).json({ error: 'Une lounge party se crée depuis une série, une saison ou une playlist vidéo.' });
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) {
const emptyMessages = { series: "Cette série n'a aucun épisode.", season: "Cette saison n'a aucun épisode.", playlist: 'Cette playlist ne contient aucune vidéo.' };
res.status(400).json({ error: emptyMessages[source.type] });
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() : defaultName;
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)
@ -868,7 +800,7 @@ 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 lounge party peut la supprimer.' });
res.status(403).json({ error: 'Seule la personne qui a créé cette watch party peut la supprimer.' });
return;
}
res.json({ ok: true });
@ -879,7 +811,7 @@ app.post('/api/static-parties/:staticId/items/:itemId/watched', requireSession,
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 lounge party.' });
res.status(404).json({ error: 'Cet élément ne fait pas partie de cette watch party.' });
return;
}
res.json({ ok: true });
@ -912,7 +844,7 @@ app.post('/api/static-parties/:staticId/session', requireSession, asyncRoute(asy
const queue = StaticPartyService.groupQueue(sp);
if (!queue.length) {
res.status(409).json({ error: 'Le groupe a déjà tout vu dans cette lounge party.' });
res.status(409).json({ error: 'Le groupe a déjà tout vu dans cette watch party.' });
return;
}
const first = queue[0]!;

View file

@ -8,11 +8,10 @@ import type { EmbyItem, EmbySession, ItemDto, LibraryDto, MediaType, PlaybackMar
// 'MusicVideo' is Emby's own item type for a musicvideos library's
// contents (distinct from the generic 'Video', which is home videos).
// 'Audio' is a music track. 'Series' isn't itself streamable, but
// launching one queues the whole series (see seriesQueueFrom and POST
// /api/parties). Season/MusicArtist/MusicAlbum get no tile button - their
// own pages have their own launch actions. "Voir sur Emby" always works.
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio', 'Series']);
// 'Audio' is a music track. Series/Season/MusicArtist/MusicAlbum aren't
// directly playable - only show "start a party" for things Emby can
// actually stream. "Voir sur Emby" always works though.
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
// Emby's /Views can return all sorts of things beyond actual media
// libraries - boxset collections, live TV, photos, books, a mixed-content
@ -69,36 +68,6 @@ export function imageUrlFor(item: EmbyItem): string {
return `/api/catalog/image/${imageItemId}`;
}
/**
* What a watch party queues from a series' full episode list (Emby's own
* order, every season - see getAllSeriesEpisodesRaw): everything from
* `fromEpisodeId` on, or - when launching the whole series - everything
* from its first regular episode on. Specials (season 0) sort first in
* Emby's list, so "the whole series" deliberately starts at S1E1, the
* same queue clicking S1E1's own button builds. An unknown
* `fromEpisodeId` yields an empty list (the caller falls back to the one
* item). Pure.
*/
export function seriesQueueFrom(episodes: EmbyItem[], fromEpisodeId?: string): EmbyItem[] {
if (fromEpisodeId) {
const index = episodes.findIndex((e) => e.Id === fromEpisodeId);
return index >= 0 ? episodes.slice(index) : [];
}
const firstRegular = episodes.findIndex((e) => e.ParentIndexNumber !== 0);
return firstRegular >= 0 ? episodes.slice(firstRegular) : episodes;
}
/**
* Just one season's episodes, in Emby's order, out of a series' full
* episode list - "launch this season". Matched by SeasonId, falling back
* to the season number for an episode Emby returned without one. Pure.
*/
export function seasonQueue(episodes: EmbyItem[], season: { Id: string; IndexNumber?: number }): EmbyItem[] {
return episodes.filter((e) => e.SeasonId
? e.SeasonId === season.Id
: season.IndexNumber != null && e.ParentIndexNumber === season.IndexNumber);
}
export function splitPlaylistByMediaType(items: EmbyItem[]): { mediaType: MediaType; items: EmbyItem[] } {
if (!items.length) return { mediaType: 'video', items: [] };
const isMusic = items[0]!.Type === 'Audio';

View file

@ -70,8 +70,7 @@ export function mergeItemLists(existing: QueueItem[], fresh: QueueItem[]): { ite
* PartyService (PartyService calls into this one), keeping the dependency
* one-way.
*
* Persisted through StaticPartyStore after every change - one file per
* party, so only the party that changed is ever rewritten.
* Persisted through StaticPartyStore after every change.
*/
class StaticPartyServiceClass {
/** Every static party, by id. */
@ -119,7 +118,7 @@ class StaticPartyServiceClass {
sp.members[creator.userId] = this.newMember(creator);
this.partiesById.set(sp.id, sp);
Logger.staticParty(`created id=${sp.id} name="${name}" source=${source.type}:${source.sourceId} items=${items.length} by=${Logger.who(creator.userId, creator.userName)}`);
this.persist(sp);
this.persist();
return sp;
}
@ -129,13 +128,13 @@ class StaticPartyServiceClass {
if (existing) {
existing.userName = who.userName;
existing.lastSeenUtc = Date.now();
this.persist(sp);
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(sp);
this.persist();
return member;
}
@ -146,7 +145,7 @@ class StaticPartyServiceClass {
if (!member) return;
delete sp.members[userId];
Logger.staticParty(`left id=${sp.id} user=${Logger.who(userId, member.userName)} members=${Object.keys(sp.members).length}`);
this.persist(sp);
this.persist();
}
/** Deletes the party for good - creator only. Returns false (and does
@ -158,7 +157,7 @@ class StaticPartyServiceClass {
}
this.partiesById.delete(sp.id);
Logger.staticParty(`deleted id=${sp.id} name="${sp.name}"`);
StaticPartyStore.remove(sp.id);
this.persist();
return true;
}
@ -170,7 +169,7 @@ class StaticPartyServiceClass {
const changed = added > 0 || items.some((item, i) => item !== sp.items[i]);
sp.items = items;
if (added) Logger.staticParty(`refreshed id=${sp.id} +${added} new item(s), total=${items.length}`);
if (changed) this.persist(sp);
if (changed) this.persist();
}
/** What the group's next session plays: everything from the group's
@ -185,7 +184,7 @@ class StaticPartyServiceClass {
if (partyId) {
sp.group.lastSessionUtc = Date.now();
Logger.staticParty(`session started id=${sp.id} partyId=${partyId} at item ${sp.group.nextIndex + 1}/${sp.items.length}`);
this.persist(sp);
this.persist();
}
}
@ -212,7 +211,7 @@ class StaticPartyServiceClass {
sp.group.resumePositionTicks = 0;
}
Logger.staticParty(`item completed id=${sp.id} mode=${link.mode} item=${itemId} by=[${connected.map((c) => c.userName).join(', ')}] group=${sp.group.completedItemIds.length}/${sp.items.length}`);
this.persist(sp);
this.persist();
}
/** The group session's current position - saved so the next session
@ -223,7 +222,7 @@ class StaticPartyServiceClass {
if (!sp || sp.items[sp.group.nextIndex]?.itemId !== itemId) return;
if (sp.group.resumePositionTicks === positionTicks) return;
sp.group.resumePositionTicks = positionTicks;
this.persist(sp);
this.persist();
}
/** A linked live party ended - forget it as the group's live session. */
@ -232,7 +231,7 @@ class StaticPartyServiceClass {
if (!sp || sp.livePartyId !== partyId) return;
sp.livePartyId = null;
Logger.staticParty(`session ended id=${sp.id} partyId=${partyId} resumeAt=item ${sp.group.nextIndex + 1} ${Math.round(sp.group.resumePositionTicks / 10000000)}s`);
this.persist(sp);
this.persist();
}
/** "Marquer comme vu" - `who` becomes a member if they weren't yet. */
@ -242,7 +241,7 @@ class StaticPartyServiceClass {
if (!member.watchedItemIds.includes(itemId)) member.watchedItemIds.push(itemId);
member.lastSeenUtc = Date.now();
Logger.staticParty(`marked watched id=${sp.id} user=${Logger.who(who.userId, who.userName)} item=${itemId}`);
this.persist(sp);
this.persist();
return true;
}
@ -292,16 +291,14 @@ class StaticPartyServiceClass {
};
}
/** Writes `sp`'s own file (debounced - see StaticPartyStore). Only the
* party that changed is ever rewritten. */
private persist(sp: StaticParty): void {
StaticPartyStore.save(sp.id, () => this.snapshotOf(sp));
/** Writes every party to disk (debounced - see StaticPartyStore). */
private persist(): void {
StaticPartyStore.save(() => this.snapshot());
}
/** `sp` minus runtime-only state. */
private snapshotOf(sp: StaticParty): PersistedStaticParty {
const { livePartyId: _live, ...persisted } = sp;
return persisted;
/** 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. */

View file

@ -5,182 +5,121 @@ import path from 'path';
import { Logger } from '../Logger';
import type { PersistedStaticParty } from '../types';
/** One party's own file - versioned so a future format change can migrate
* instead of silently misreading an older file. */
/** 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;
party: PersistedStaticParty;
}
/** The first release's single-file format - only read once, to migrate. */
interface LegacyStaticPartiesFile {
version: 1;
parties: PersistedStaticParty[];
}
/** How long a save waits for more changes to the same party before
* writing - progress updates arrive in bursts (a whole party's members
* at once, the 20s position sweep). */
/** 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;
/** Party ids become file names - anything else is refused outright, so a
* crafted id can never reach outside the directory. */
const SAFE_ID = /^[A-Za-z0-9-]+$/;
/**
* The static watch parties' persistence: **one JSON file per party**,
* `${DATA_DIR}/static-parties/<id>.json` - the first and only thing this
* app keeps on disk (everything else is deliberately in-memory - see
* docs/architecture.md's "Party lifecycle").
* 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.
*
* One file per party (rather than a single file for all of them) limits
* what any one problem can damage: a write only ever touches the party
* that changed, and a corrupt file costs that one party, never the rest.
* Each write is also atomic (a `.tmp` sibling, then a rename over the
* real file), so a crash mid-write leaves the previous version intact.
*
* Writes are debounced per party (SAVE_DEBOUNCE_MS). A corrupt file is
* logged and moved aside (`.corrupt`) rather than overwritten or
* silently dropped - the server must never refuse to start over one, and
* nothing is lost for good. The first release's single
* `static-parties.json` is migrated once, on the first load that finds it.
*
* JSON files rather than a database: the data is tiny, 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 {
/** `${DATA_DIR}/static-parties` - see configure(). */
private dir: string | null = null;
/** The first release's single file, migrated on load if still present. */
private legacyFile: string | null = null;
/** Pending debounced saves, by party id. */
private timers = new Map<string, NodeJS.Timeout>();
/** What each pending save will write - always the latest snapshot. */
private pending = new Map<string, () => PersistedStaticParty>();
/** 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`. Must be called before anything else -
* index.ts does it at startup, tests with a temp directory. */
/** 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.dir = path.resolve(dataDir, 'static-parties');
this.legacyFile = path.resolve(dataDir, 'static-parties.json');
this.filePath = path.resolve(dataDir, 'static-parties.json');
}
/** Reads every persisted party - synchronous, startup-only. */
public load(): PersistedStaticParty[] {
const dir = this.requireDir();
const file = this.requirePath();
let raw: string;
try {
fs.mkdirSync(dir, { recursive: true });
raw = fs.readFileSync(file, 'utf8');
} catch (err) {
Logger.staticParty(`could not create ${dir} - starting empty: ${(err as Error).message}`);
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 [];
}
this.migrateLegacyFile();
let names: string[];
try {
names = fs.readdirSync(dir);
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(`could not list ${dir} - starting empty: ${(err as Error).message}`);
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 [];
}
const parties: PersistedStaticParty[] = [];
for (const name of names) {
const file = path.join(dir, name);
if (name.endsWith('.tmp')) {
// A write interrupted before its rename - the real file next to it
// (if any) is still the last complete version.
this.tryUnlink(file);
continue;
}
if (!name.endsWith('.json')) continue; // .corrupt files and anything else
const party = this.readPartyFile(file);
if (party) parties.push(party);
}
Logger.staticParty(`loaded ${parties.length} static party(ies) from ${dir}`);
return parties;
}
/** Schedules a debounced write of `id`'s own file, from whatever
* `snapshot` returns at write time (so a burst of changes only ever
* writes the final state). */
public save(id: string, snapshot: () => PersistedStaticParty): void {
if (!SAFE_ID.test(id)) {
Logger.staticParty(`save refused: unsafe id "${id}"`);
return;
}
this.pending.set(id, snapshot);
if (this.timers.has(id)) return;
const timer = setTimeout(() => {
this.timers.delete(id);
this.writeOne(id).catch((err: unknown) => {
Logger.staticParty(`save failed id=${id}: ${(err as Error).message}`);
/** 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);
timer.unref(); // a pending save must never keep a test process (or a shutdown) alive
this.timers.set(id, timer);
this.saveTimer.unref(); // a pending save must never keep a test process (or a shutdown) alive
}
/** Deletes `id`'s file - and cancels any save still pending for it, so
* a debounced write can't recreate a party that was just deleted. */
public remove(id: string): void {
const timer = this.timers.get(id);
if (timer) clearTimeout(timer);
this.timers.delete(id);
this.pending.delete(id);
if (!SAFE_ID.test(id)) return;
const file = this.fileFor(id);
try {
fs.unlinkSync(file);
Logger.staticParty(`removed ${file}`);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
Logger.staticParty(`could not remove ${file}: ${(err as Error).message}`);
}
}
}
/** Writes every pending save immediately and synchronously - for a
* graceful shutdown (SIGTERM/SIGINT), where an async write could be cut
* short by the process exiting. */
/** 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 {
for (const timer of this.timers.values()) clearTimeout(timer);
this.timers.clear();
const pending = Array.from(this.pending.entries());
this.pending.clear();
for (const [id, snapshot] of pending) {
const file = this.fileFor(id);
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 id=${id}: ${(err as Error).message}`);
Logger.staticParty(`flush failed: ${(err as Error).message}`);
}
}
if (pending.length) Logger.staticParty(`flushed ${pending.length} static party(ies)`);
}
/** Writes every pending save now (async) - lets tests wait for disk. */
/** Writes the pending snapshot now - atomically, via a `.tmp` rename. */
public async writeNow(): Promise<void> {
for (const timer of this.timers.values()) clearTimeout(timer);
this.timers.clear();
const ids = Array.from(this.pending.keys());
try {
await Promise.all(ids.map((id) => this.writeOne(id)));
} catch (err) {
Logger.staticParty(`writeNow failed: ${(err as Error).message}`);
throw err;
}
}
/** Writes `id`'s pending snapshot - atomically, via a `.tmp` rename. */
private async writeOne(id: string): Promise<void> {
const snapshot = this.pending.get(id);
if (!snapshot) return;
this.pending.delete(id);
const file = this.fileFor(id);
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');
@ -191,81 +130,16 @@ class StaticPartyStoreClass {
}
}
/** One party file's content, or null (moved aside) if it's unreadable. */
private readPartyFile(file: string): PersistedStaticParty | null {
try {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Partial<StaticPartyFile>;
const party = parsed?.party;
if (!party || typeof party.id !== 'string' || !Array.isArray(party.items)) {
throw new Error('missing or malformed "party"');
}
if (path.basename(file) !== `${party.id}.json`) throw new Error(`file name does not match id ${party.id}`);
return party;
} catch (err) {
Logger.staticParty(`corrupt file ${file} - moved aside to .corrupt, skipped: ${(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 null;
}
}
/**
* The first release kept every party in one `static-parties.json` -
* split it into one file per party, then rename it to `.migrated` (kept,
* not deleted, in case anything went wrong). A party that already has
* its own file is left alone: that file is newer.
*/
private migrateLegacyFile(): void {
const legacy = this.legacyFile!;
if (!fs.existsSync(legacy)) return;
try {
const parsed = JSON.parse(fs.readFileSync(legacy, 'utf8')) as Partial<LegacyStaticPartiesFile>;
const parties = Array.isArray(parsed?.parties) ? parsed.parties : [];
let migrated = 0;
for (const party of parties) {
if (!party || typeof party.id !== 'string' || !SAFE_ID.test(party.id)) continue;
const file = this.fileFor(party.id);
if (fs.existsSync(file)) continue;
fs.writeFileSync(`${file}.tmp`, this.serialize(party), 'utf8');
fs.renameSync(`${file}.tmp`, file);
migrated++;
}
fs.renameSync(legacy, `${legacy}.migrated`);
Logger.staticParty(`migrated ${migrated} static party(ies) from ${legacy} to one file each (original kept as .migrated)`);
} catch (err) {
Logger.staticParty(`could not migrate ${legacy} - left in place: ${(err as Error).message}`);
}
}
/** `id`'s own file path. */
private fileFor(id: string): string {
return path.join(this.requireDir(), `${id}.json`);
}
/** A party file's JSON text. */
private serialize(party: PersistedStaticParty): string {
const data: StaticPartyFile = { version: 1, party };
/** The file's JSON text for `parties`. */
private serialize(parties: PersistedStaticParty[]): string {
const data: StaticPartyFile = { version: 1, parties };
return JSON.stringify(data, null, 2);
}
/** Best-effort delete - logs anything but "already gone". */
private tryUnlink(file: string): void {
try {
fs.unlinkSync(file);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
Logger.staticParty(`could not remove ${file}: ${(err as Error).message}`);
}
}
}
/** The configured directory - throws if configure() was never called. */
private requireDir(): string {
if (!this.dir) throw new Error('StaticPartyStore.configure() was never called');
return this.dir;
/** 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;
}
}

View file

@ -173,8 +173,6 @@ export interface EmbyItem {
Type?: string;
SeriesId?: string;
SeriesName?: string;
/** An episode's own season - see CatalogService.seasonQueue. */
SeasonId?: string;
ParentIndexNumber?: number;
IndexNumber?: number;
RunTimeTicks?: number;
@ -454,10 +452,6 @@ export interface LobbyPartyDto {
// --- Static (persistent) watch party ------------------------------------------
/** A lounge party's source: a whole series, one season of it, or a
* video playlist. */
export type StaticPartySourceType = 'series' | 'season' | 'playlist';
/** One member of a static watch party - persisted, independent of any
* live connection (unlike PartyMember). */
export interface StaticPartyMember {
@ -487,10 +481,7 @@ export interface StaticPartyGroupProgress {
export interface StaticParty {
id: string;
name: string;
/** What the items come from. A `season` also records its series
* (`seriesId`) and number (`seasonNumber`) - episodes are fetched per
* series, then filtered (see CatalogService.seasonQueue). */
source: { type: StaticPartySourceType; sourceId: string; seriesId?: string; seasonNumber?: number };
source: { type: 'series' | 'playlist'; sourceId: string };
imageUrl: string;
createdByUserId: string;
createdUtc: number;
@ -515,7 +506,7 @@ export interface StaticPartyItemDto extends QueueItem {
export interface StaticPartySummaryDto {
id: string;
name: string;
sourceType: StaticPartySourceType;
sourceType: 'series' | 'playlist';
imageUrl: string;
itemCount: number;
groupWatchedCount: number;

View file

@ -4,7 +4,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { Request, Response } from 'express';
import { CatalogService, imageUrlFor, seasonQueue, seriesQueueFrom, splitPlaylistByMediaType } from '../src/services/CatalogService';
import { CatalogService, imageUrlFor, splitPlaylistByMediaType } from '../src/services/CatalogService';
import { EmbyClient } from '../src/emby/EmbyClient';
import type { EmbyItem, EmbySession } from '../src/types';
@ -314,34 +314,3 @@ describe('imageUrlFor', () => {
assert.equal(imageUrlFor({ Id: 't1', Name: 'Track', Type: 'Audio' }), '/api/catalog/image/t1');
});
});
describe('seriesQueueFrom / seasonQueue', () => {
const ep = (id: string, season: number, seasonId?: string): EmbyItem => ({ Id: id, Name: id, Type: 'Episode', ParentIndexNumber: season, SeasonId: seasonId });
// Emby's own order: specials (season 0) first, then every season in turn.
const all = [ep('sp1', 0, 'se0'), ep('s1e1', 1, 'se1'), ep('s1e2', 1, 'se1'), ep('s2e1', 2, 'se2'), ep('s2e2', 2, 'se2')];
it('the whole series starts at S1E1 - specials sort first and are skipped', () => {
assert.deepEqual(seriesQueueFrom(all).map((e) => e.Id), ['s1e1', 's1e2', 's2e1', 's2e2']);
});
it('a series with only specials still queues them', () => {
assert.deepEqual(seriesQueueFrom([ep('sp1', 0), ep('sp2', 0)]).map((e) => e.Id), ['sp1', 'sp2']);
});
it('from an episode, queues it and everything after it, across seasons', () => {
assert.deepEqual(seriesQueueFrom(all, 's1e2').map((e) => e.Id), ['s1e2', 's2e1', 's2e2']);
});
it('an unknown starting episode yields nothing (the caller falls back to that one item)', () => {
assert.deepEqual(seriesQueueFrom(all, 'nope'), []);
});
it('a season queues only its own episodes', () => {
assert.deepEqual(seasonQueue(all, { Id: 'se1', IndexNumber: 1 }).map((e) => e.Id), ['s1e1', 's1e2']);
});
it('a season falls back to the season number for episodes without a SeasonId', () => {
const noIds = [ep('a', 1), ep('b', 2), ep('c', 2)];
assert.deepEqual(seasonQueue(noIds, { Id: 'se2', IndexNumber: 2 }).map((e) => e.Id), ['b', 'c']);
});
});

View file

@ -22,12 +22,10 @@ before(() => {
StaticPartyStore.configure(dataDir);
});
const partiesDir = () => path.join(dataDir, 'static-parties');
beforeEach(() => {
StaticPartyStore.flushSync(); // no debounced save from a previous test may land mid-test
fs.rmSync(partiesDir(), { recursive: true, force: true });
for (const name of fs.readdirSync(dataDir)) fs.rmSync(path.join(dataDir, name), { recursive: true, force: true });
fs.rmSync(path.join(dataDir, 'static-parties.json'), { force: true });
fs.rmSync(path.join(dataDir, 'static-parties.json.corrupt'), { force: true });
StaticPartyService.init();
});
@ -168,94 +166,31 @@ describe('StaticPartyService', () => {
});
describe('StaticPartyStore', () => {
it('writes one file per party, and round-trips it without runtime-only state', async () => {
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);
const other = StaticPartyService.create(bob, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
StaticPartyService.setLiveParty(sp, 'live-1');
await StaticPartyStore.writeNow();
assert.deepEqual(fs.readdirSync(partiesDir()).sort(), [`${sp.id}.json`, `${other.id}.json`].sort());
const onDisk = JSON.parse(fs.readFileSync(path.join(partiesDir(), `${sp.id}.json`), 'utf8'));
const onDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'static-parties.json'), 'utf8'));
assert.equal(onDisk.version, 1);
assert.equal('livePartyId' in onDisk.party, false);
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');
assert.ok(StaticPartyService.getById(other.id));
});
it('a change only rewrites the party that changed', async () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
const other = StaticPartyService.create(bob, { type: 'series', sourceId: 's1' }, 'B', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
const otherFile = path.join(partiesDir(), `${other.id}.json`);
const past = new Date(Date.now() - 60000);
fs.utimesSync(otherFile, past, past);
// Compared against what the filesystem itself reports back, not
// past.getTime(): Linux returns mtimeMs as a float that can land a
// hair off the exact millisecond set (e.g. ...780.999 for ...781).
const mtimeBefore = fs.statSync(otherFile).mtimeMs;
StaticPartyService.markWatched(sp, alice, 'e1');
await StaticPartyStore.writeNow();
assert.equal(fs.statSync(otherFile).mtimeMs, mtimeBefore, 'the untouched party\'s file was not rewritten');
});
it('deleting a party removes its file, even with a save still pending', async () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
StaticPartyService.markWatched(sp, alice, 'e1'); // schedules a save...
StaticPartyService.delete(sp, alice.userId); // ...that must not recreate the file
await StaticPartyStore.writeNow();
assert.equal(fs.existsSync(path.join(partiesDir(), `${sp.id}.json`)), false);
});
it('a corrupt file is moved aside and only that party is lost', async () => {
const good = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'Good', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
fs.writeFileSync(path.join(partiesDir(), 'broken-id.json'), '{ not json', 'utf8');
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.deepEqual(StaticPartyService.list().map((p) => p.id), [good.id]);
assert.ok(fs.existsSync(path.join(partiesDir(), 'broken-id.json.corrupt')));
assert.equal(StaticPartyService.list().length, 0);
assert.ok(fs.existsSync(path.join(dataDir, 'static-parties.json.corrupt')));
});
it('a leftover .tmp from an interrupted write is cleaned up, the real file kept', async () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
fs.writeFileSync(path.join(partiesDir(), `${sp.id}.json.tmp`), '{ half-writ', 'utf8');
StaticPartyService.init();
assert.ok(StaticPartyService.getById(sp.id));
assert.equal(fs.existsSync(path.join(partiesDir(), `${sp.id}.json.tmp`)), false);
});
it('migrates the first release\'s single static-parties.json into one file per party', () => {
const legacy = { version: 1, parties: [
{ id: 'aaa-1', name: 'One', source: { type: 'series', sourceId: 's1' }, imageUrl: '', createdByUserId: 'u-alice', createdUtc: 1,
items: ITEMS, group: { nextIndex: 1, resumePositionTicks: 0, completedItemIds: ['e1'], lastSessionUtc: null }, members: {} },
{ id: 'bbb-2', name: 'Two', source: { type: 'series', sourceId: 's1' }, imageUrl: '', createdByUserId: 'u-bob', createdUtc: 2,
items: ITEMS, group: { nextIndex: 0, resumePositionTicks: 0, completedItemIds: [], lastSessionUtc: null }, members: {} }
] };
fs.writeFileSync(path.join(dataDir, 'static-parties.json'), JSON.stringify(legacy), 'utf8');
StaticPartyService.init();
assert.deepEqual(StaticPartyService.list().map((p) => p.id).sort(), ['aaa-1', 'bbb-2']);
assert.deepEqual(StaticPartyService.getById('aaa-1')!.group.completedItemIds, ['e1']);
assert.ok(fs.existsSync(path.join(partiesDir(), 'aaa-1.json')));
assert.equal(fs.existsSync(path.join(dataDir, 'static-parties.json')), false);
assert.ok(fs.existsSync(path.join(dataDir, 'static-parties.json.migrated')), 'the original is kept, not deleted');
});
it('an empty data directory is a normal first start', () => {
it('a missing file is a normal first start', () => {
StaticPartyService.init();
assert.equal(StaticPartyService.list().length, 0);
});

View file

@ -1,16 +1,4 @@
# Lounge party (watch party statique)
## Les trois types de party
| Nom dans l'interface | Quoi | Où |
|---|---|---|
| **Watch party** | party vidéo classique, éphémère | accueil, « Parties en cours » |
| **Listen party** | party audio classique, éphémère (barre musique) | accueil, « Parties en cours » |
| **Lounge party** | party statique : une série ou une playlist vidéo suivie sur plusieurs séances | onglet « Lounge parties » |
Côté code, la lounge party reste nommée « static party » (`StaticPartyService`,
route `/static`, `staticLink`...). Seuls les libellés visibles ont changé. Sur
l'accueil, chaque carte affiche un badge « Watch party » ou « Listen party ».
# Watch party statique (permanente)
## Objectif
@ -79,25 +67,15 @@ des watch parties en cours.
devient l'hôte.
- Quitter efface sa propre progression.
- Seul le créateur peut supprimer la watch party.
- **Contenus** : une série entière, **une seule saison**, ou une playlist
vidéo. Une playlist musicale est refusée.
- Série entière : tous les épisodes de toutes les saisons à partir de S1E1.
Les spéciaux (saison 0) sont exclus : Emby les trie en premier, et une
séance commencerait par eux (`CatalogService.seriesQueueFrom`). Les
lounge parties créées avant ce changement gardent leurs spéciaux déjà
en liste.
- Saison : uniquement les épisodes de cette saison
(`CatalogService.seasonQueue`, par `SeasonId`, sinon par numéro de
saison). La source garde `seriesId` et `seasonNumber`, car les
épisodes sont récupérés par série puis filtrés. Nom par défaut :
« Série - Saison », avec l'affiche de la série.
- **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, saison, épisode (sa série) ou playlist |
| 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) |
@ -107,24 +85,14 @@ des watch parties en cours.
## Persistance
- `StaticPartyStore` écrit **un fichier JSON par watch party** :
`${DATA_DIR}/static-parties/<id>.json`, au format `{version: 1, party}`.
- `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`.
- Un fichier par party limite les dégâts : une écriture ne touche que la
party modifiée, et un fichier corrompu ne coûte que cette party-là.
- L'écriture est atomique (`.tmp` puis `rename`) et debouncée (2 s) party
par party. Elle est flushée de façon synchrone sur `SIGTERM`/`SIGINT`.
- Au démarrage, un `.tmp` restant (écriture interrompue) est supprimé ; le
vrai fichier, s'il existe, est la dernière version complète.
- Un fichier corrompu (ou dont le nom ne correspond pas à l'id) est
renommé en `.corrupt` et ignoré ; les autres parties se chargent
normalement. Le serveur ne crashe jamais pour ça.
- Supprimer une party supprime son fichier et annule toute sauvegarde en
attente pour elle.
- Migration : l'ancien fichier unique `${DATA_DIR}/static-parties.json` de
la première version est éclaté en un fichier par party au premier
démarrage, puis renommé en `.migrated` (conservé, pas supprimé).
- 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.
@ -141,19 +109,13 @@ des watch parties en cours.
solo, ou lancer une watch party de rattrapage ;
- les membres ;
- la liste complète des items.
- Page série (`views/series`) :
- en-tête : « Lancer toute la série » (watch party) et « Créer une lounge
party » (série entière) ;
- au-dessus des épisodes de l'onglet de saison sélectionné : « Lancer
cette saison » (watch party) et « Lounge party de cette saison ».
- « Créer une lounge party » sur `views/playlist` (vidéo uniquement).
- Onglet dédié « Lounge parties » (sidebar et menu mobile), route
`/static` (`views/staticParties/`) : une carte par watch party avec la
progression, le prochain élément, un badge « N manqués » et une pastille
« séance en cours ». L'onglet reste actif sur la page d'une party.
- Séparation avec les watch/listen parties classiques : la liste « Parties
en cours » de l'accueil exclut les séances de lounge party
(`staticPartyId` non nul) ; elles ne s'affichent que dans leur onglet.
- 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