Compare commits

..

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

22 changed files with 255 additions and 584 deletions

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

@ -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

@ -5,7 +5,7 @@
<h1 id="seriesTitle"></h1>
<p class="seriesOverview hide" id="seriesOverview"></p>
<div class="seriesActions">
<button 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>

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,76 +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 { 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">${p.sourceType === 'series' ? 'Série' : 'Playlist'} · ${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

@ -34,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;
}
@ -144,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);
@ -160,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,7 +3,7 @@ import { navigate } from '../../router';
import { showToast } from '../../toast';
/**
* "Créer une lounge party" - shared by views/series and
* "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.

View file

@ -4576,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

@ -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'));
@ -556,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 || {};
@ -587,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;
@ -662,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) });
@ -721,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;
}
@ -755,7 +755,7 @@ app.post('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
} 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 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;
}
@ -800,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 });
@ -811,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 });
@ -844,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

@ -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

@ -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,90 +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);
StaticPartyService.markWatched(sp, alice, 'e1');
await StaticPartyStore.writeNow();
assert.equal(fs.statSync(otherFile).mtimeMs, past.getTime(), '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
@ -97,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.
@ -131,15 +109,13 @@ des watch parties en cours.
solo, ou lancer une watch party de rattrapage ;
- les membres ;
- la liste complète des items.
- Bouton « Créer une lounge party » sur `views/series` et sur
- Bouton « Créer une watch party permanente » sur `views/series` et 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.
- 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