Give static watch parties their own tab and one file per party
- new "Parties permanentes" tab (/static, sidebar + mobile sheet), highlighted on a party's own page too; the home page no longer lists static parties, and its live "Watch parties en cours" list leaves their sessions out - StaticPartyStore now writes data/static-parties/<id>.json - one file per party, debounced per party, so a write only touches the party that changed and a corrupt file only costs that party (moved aside to .corrupt, the rest still load); leftover .tmp files are cleaned up; deleting a party removes its file and cancels any pending save - the first release's single static-parties.json is migrated once on load, then kept as .migrated Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2de375ac62
commit
1e437e4a91
15 changed files with 519 additions and 224 deletions
|
|
@ -11,6 +11,7 @@ import { renderAlbum } from './views/album/Album';
|
||||||
import { renderPlaylist } from './views/playlist/Playlist';
|
import { renderPlaylist } from './views/playlist/Playlist';
|
||||||
import { renderPartyPlayer } from './views/player/Player';
|
import { renderPartyPlayer } from './views/player/Player';
|
||||||
import { renderStaticParty } from './views/staticParty/StaticParty';
|
import { renderStaticParty } from './views/staticParty/StaticParty';
|
||||||
|
import { renderStaticParties } from './views/staticParties/StaticParties';
|
||||||
|
|
||||||
route('/', () => navigate('/home'));
|
route('/', () => navigate('/home'));
|
||||||
route('/login', renderLogin);
|
route('/login', renderLogin);
|
||||||
|
|
@ -21,6 +22,7 @@ route('/artist/:id', renderArtist);
|
||||||
route('/album/:id', renderAlbum);
|
route('/album/:id', renderAlbum);
|
||||||
route('/playlist/:id', renderPlaylist);
|
route('/playlist/:id', renderPlaylist);
|
||||||
route('/party/:id', renderPartyPlayer);
|
route('/party/:id', renderPartyPlayer);
|
||||||
|
route('/static', renderStaticParties);
|
||||||
route('/static/:id', renderStaticParty);
|
route('/static/:id', renderStaticParty);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
<div class="sidebarBrand">Watch Party</div>
|
<div class="sidebarBrand">Watch Party</div>
|
||||||
<nav class="sidebarNav" id="sidebarNav">
|
<nav class="sidebarNav" id="sidebarNav">
|
||||||
<a href="#/home" class="navItem">Accueil</a>
|
<a href="#/home" class="navItem">Accueil</a>
|
||||||
|
<a href="#/static" class="navItem">Parties permanentes</a>
|
||||||
<div class="navSectionLabel">Bibliothèques</div>
|
<div class="navSectionLabel">Bibliothèques</div>
|
||||||
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
|
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -41,6 +42,7 @@
|
||||||
plain scrim tap-only close used to leave unsolved). -->
|
plain scrim tap-only close used to leave unsolved). -->
|
||||||
<div class="sheetGrabberHandle" id="sheetGrabberHandle"><div class="sheetGrabber"></div></div>
|
<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="#/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>Parties permanentes</a>
|
||||||
<div class="navSectionLabel">Bibliothèques</div>
|
<div class="navSectionLabel">Bibliothèques</div>
|
||||||
<div class="sheetGrid" id="mobileLibGrid"></div>
|
<div class="sheetGrid" id="mobileLibGrid"></div>
|
||||||
<div id="mobilePlaylistsSlot"></div>
|
<div id="mobilePlaylistsSlot"></div>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ 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 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 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>';
|
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 "Watch parties permanentes" tab - 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
|
// Emby could in principle add a CollectionType this app doesn't map yet
|
||||||
// (server/src/services/CatalogService.ts's VISIBLE_COLLECTION_TYPES
|
// (server/src/services/CatalogService.ts's VISIBLE_COLLECTION_TYPES
|
||||||
// would need updating first either way) - falls back to a plain folder
|
// would need updating first either way) - falls back to a plain folder
|
||||||
|
|
@ -53,6 +55,7 @@ export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
|
||||||
appEl.innerHTML = html;
|
appEl.innerHTML = html;
|
||||||
|
|
||||||
appEl.querySelector('[data-icon="home"]')!.innerHTML = HOME_ICON_SVG;
|
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
|
// Mounted once, outside #mainContent, so a listen party survives
|
||||||
// navigating the router - see musicBar/MusicBar.ts's header comment.
|
// navigating the router - see musicBar/MusicBar.ts's header comment.
|
||||||
|
|
@ -98,8 +101,11 @@ export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
|
||||||
|
|
||||||
const highlight = () => {
|
const highlight = () => {
|
||||||
appEl.querySelectorAll('.navItem').forEach((a) => {
|
appEl.querySelectorAll('.navItem').forEach((a) => {
|
||||||
const isHome = a.getAttribute('href') === '#/home';
|
const href = a.getAttribute('href');
|
||||||
a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash));
|
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);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
highlight();
|
highlight();
|
||||||
|
|
|
||||||
|
|
@ -60,41 +60,3 @@
|
||||||
color: var(--accent-bright);
|
color: var(--accent-bright);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Static (persistent) watch parties ----
|
|
||||||
A whole card is the link (to /static/:id) - reuses .partyCard's layout,
|
|
||||||
minus the default anchor styling. */
|
|
||||||
.staticCard {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.staticCard:hover { background: #2b252e; }
|
|
||||||
|
|
||||||
.staticCardLive {
|
|
||||||
color: var(--accent-bright);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.staticCardMissed {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--danger);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A live session's link back to the static watch party it belongs to. */
|
|
||||||
.partyCardStatic {
|
|
||||||
color: var(--accent-bright);
|
|
||||||
font-size: 12px;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.partyCardStatic:hover { text-decoration: underline; }
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,4 @@
|
||||||
<div id="partiesCarousel" class="carousel"></div>
|
<div id="partiesCarousel" class="carousel"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section" id="staticSection" hidden>
|
|
||||||
<h2>Watch parties permanentes</h2>
|
|
||||||
<div id="staticCarousel" class="carousel"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div id="recentSections"></div>
|
<div id="recentSections"></div>
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ import { navigate } from '../../router';
|
||||||
import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles';
|
import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles';
|
||||||
import { LOADING_HINT } from '../../loading';
|
import { LOADING_HINT } from '../../loading';
|
||||||
import { showToast } from '../../toast';
|
import { showToast } from '../../toast';
|
||||||
import { listStaticParties } from '../../api/staticParty';
|
import type { LobbyPartyDto } from '../../types';
|
||||||
import type { LobbyPartyDto, StaticPartySummaryDto } from '../../types';
|
|
||||||
|
|
||||||
export function renderHome(root: HTMLElement): () => void {
|
export function renderHome(root: HTMLElement): () => void {
|
||||||
root.innerHTML = html;
|
root.innerHTML = html;
|
||||||
|
|
@ -19,20 +18,9 @@ export function renderHome(root: HTMLElement): () => void {
|
||||||
// label is the one piece that ticks purely client-side (from
|
// label is the one piece that ticks purely client-side (from
|
||||||
// createdUtc) - no point re-pushing from the server once a second just
|
// createdUtc) - no point re-pushing from the server once a second just
|
||||||
// for a clock.
|
// for a clock.
|
||||||
// Static watch parties are a REST fetch (their data changes rarely), but
|
// A static watch party's sessions live in their own tab
|
||||||
// re-fetched whenever the set of live static-party sessions in the lobby
|
// (views/staticParties), not here - this list is classic parties only.
|
||||||
// changes - a session starting/ending is what moves their cards'
|
const lobby = connectLobby((parties) => renderParties(root, parties.filter((p) => !p.staticPartyId)));
|
||||||
// "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);
|
const elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000);
|
||||||
|
|
||||||
loadRecentSections(root);
|
loadRecentSections(root);
|
||||||
|
|
@ -72,7 +60,6 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
|
||||||
<img class="${p.mediaType === 'music' ? 'partyCardArt--square' : ''}" src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
|
<img class="${p.mediaType === 'music' ? 'partyCardArt--square' : ''}" src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
|
||||||
<div class="partyCardInfo">
|
<div class="partyCardInfo">
|
||||||
<strong>${escapeHtml(p.itemName || '')}</strong>
|
<strong>${escapeHtml(p.itemName || '')}</strong>
|
||||||
${p.staticPartyId ? `<a class="partyCardStatic" href="#/static/${escapeHtml(p.staticPartyId)}">↻ ${escapeHtml(p.staticPartyName || '')}</a>` : ''}
|
|
||||||
<span class="hint">
|
<span class="hint">
|
||||||
${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}
|
${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}
|
||||||
· <span class="elapsed"
|
· <span class="elapsed"
|
||||||
|
|
@ -108,39 +95,6 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
|
||||||
updateElapsedLabels(root);
|
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
|
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
81
client/src/views/staticParties/StaticParties.css
Normal file
81
client/src/views/staticParties/StaticParties.css
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
/* ---- "Watch parties permanentes" 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; }
|
||||||
|
}
|
||||||
9
client/src/views/staticParties/StaticParties.html
Normal file
9
client/src/views/staticParties/StaticParties.html
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<div class="pageHeader">
|
||||||
|
<h1>Watch parties permanentes</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 watch party permanente ».
|
||||||
|
</p>
|
||||||
|
<div class="staticGrid" id="staticGrid"></div>
|
||||||
76
client/src/views/staticParties/StaticParties.ts
Normal file
76
client/src/views/staticParties/StaticParties.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
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 "Watch parties permanentes" 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 watch party permanente 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('');
|
||||||
|
}
|
||||||
|
|
@ -164,7 +164,7 @@ function wireActions(root: HTMLElement, staticId: string): void {
|
||||||
runBusy(deleteBtn, async () => {
|
runBusy(deleteBtn, async () => {
|
||||||
await deleteStaticParty(staticId);
|
await deleteStaticParty(staticId);
|
||||||
showToast('Watch party supprimée.');
|
showToast('Watch party supprimée.');
|
||||||
navigate('/home');
|
navigate('/static');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4576,10 +4576,16 @@ short version:
|
||||||
so the player, sync, shared stream, lobby and host handoff are all
|
so the player, sync, shared stream, lobby and host handoff are all
|
||||||
reused unchanged. `PartyService` calls into `StaticPartyService`,
|
reused unchanged. `PartyService` calls into `StaticPartyService`,
|
||||||
never the other way around.
|
never the other way around.
|
||||||
- **This app's first on-disk state.** A JSON file
|
- **This app's first on-disk state.** One JSON file per static party
|
||||||
(`StaticPartyStore`, `DATA_DIR`) that is atomic, debounced, and
|
(`StaticPartyStore`, `${DATA_DIR}/static-parties/<id>.json`). Writes
|
||||||
flushed on SIGTERM. "Party lifecycle" above still holds for ordinary
|
are atomic, debounced per party, and flushed on SIGTERM. It started
|
||||||
parties.
|
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.** "Parties permanentes" (`/static`) lists them. The
|
||||||
|
home page's live "Watch parties en cours" list leaves their sessions
|
||||||
|
out, so classic and permanent parties never share a list.
|
||||||
- **"Completed" gained a second trigger.** `PartyService
|
- **"Completed" gained a second trigger.** `PartyService
|
||||||
.completeCurrentItem` is now the one place an item counts as done. It
|
.completeCurrentItem` is now the one place an item counts as done. It
|
||||||
handles Emby Played, as before, plus static progress. It runs from
|
handles Emby Played, as before, plus static progress. It runs from
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,8 @@ export function mergeItemLists(existing: QueueItem[], fresh: QueueItem[]): { ite
|
||||||
* PartyService (PartyService calls into this one), keeping the dependency
|
* PartyService (PartyService calls into this one), keeping the dependency
|
||||||
* one-way.
|
* one-way.
|
||||||
*
|
*
|
||||||
* Persisted through StaticPartyStore after every change.
|
* Persisted through StaticPartyStore after every change - one file per
|
||||||
|
* party, so only the party that changed is ever rewritten.
|
||||||
*/
|
*/
|
||||||
class StaticPartyServiceClass {
|
class StaticPartyServiceClass {
|
||||||
/** Every static party, by id. */
|
/** Every static party, by id. */
|
||||||
|
|
@ -118,7 +119,7 @@ class StaticPartyServiceClass {
|
||||||
sp.members[creator.userId] = this.newMember(creator);
|
sp.members[creator.userId] = this.newMember(creator);
|
||||||
this.partiesById.set(sp.id, sp);
|
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)}`);
|
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();
|
this.persist(sp);
|
||||||
return sp;
|
return sp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,13 +129,13 @@ class StaticPartyServiceClass {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.userName = who.userName;
|
existing.userName = who.userName;
|
||||||
existing.lastSeenUtc = Date.now();
|
existing.lastSeenUtc = Date.now();
|
||||||
this.persist();
|
this.persist(sp);
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
const member = this.newMember(who);
|
const member = this.newMember(who);
|
||||||
sp.members[who.userId] = member;
|
sp.members[who.userId] = member;
|
||||||
Logger.staticParty(`joined id=${sp.id} user=${Logger.who(who.userId, who.userName)} members=${Object.keys(sp.members).length}`);
|
Logger.staticParty(`joined id=${sp.id} user=${Logger.who(who.userId, who.userName)} members=${Object.keys(sp.members).length}`);
|
||||||
this.persist();
|
this.persist(sp);
|
||||||
return member;
|
return member;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,7 +146,7 @@ class StaticPartyServiceClass {
|
||||||
if (!member) return;
|
if (!member) return;
|
||||||
delete sp.members[userId];
|
delete sp.members[userId];
|
||||||
Logger.staticParty(`left id=${sp.id} user=${Logger.who(userId, member.userName)} members=${Object.keys(sp.members).length}`);
|
Logger.staticParty(`left id=${sp.id} user=${Logger.who(userId, member.userName)} members=${Object.keys(sp.members).length}`);
|
||||||
this.persist();
|
this.persist(sp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deletes the party for good - creator only. Returns false (and does
|
/** Deletes the party for good - creator only. Returns false (and does
|
||||||
|
|
@ -157,7 +158,7 @@ class StaticPartyServiceClass {
|
||||||
}
|
}
|
||||||
this.partiesById.delete(sp.id);
|
this.partiesById.delete(sp.id);
|
||||||
Logger.staticParty(`deleted id=${sp.id} name="${sp.name}"`);
|
Logger.staticParty(`deleted id=${sp.id} name="${sp.name}"`);
|
||||||
this.persist();
|
StaticPartyStore.remove(sp.id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,7 +170,7 @@ class StaticPartyServiceClass {
|
||||||
const changed = added > 0 || items.some((item, i) => item !== sp.items[i]);
|
const changed = added > 0 || items.some((item, i) => item !== sp.items[i]);
|
||||||
sp.items = items;
|
sp.items = items;
|
||||||
if (added) Logger.staticParty(`refreshed id=${sp.id} +${added} new item(s), total=${items.length}`);
|
if (added) Logger.staticParty(`refreshed id=${sp.id} +${added} new item(s), total=${items.length}`);
|
||||||
if (changed) this.persist();
|
if (changed) this.persist(sp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the group's next session plays: everything from the group's
|
/** What the group's next session plays: everything from the group's
|
||||||
|
|
@ -184,7 +185,7 @@ class StaticPartyServiceClass {
|
||||||
if (partyId) {
|
if (partyId) {
|
||||||
sp.group.lastSessionUtc = Date.now();
|
sp.group.lastSessionUtc = Date.now();
|
||||||
Logger.staticParty(`session started id=${sp.id} partyId=${partyId} at item ${sp.group.nextIndex + 1}/${sp.items.length}`);
|
Logger.staticParty(`session started id=${sp.id} partyId=${partyId} at item ${sp.group.nextIndex + 1}/${sp.items.length}`);
|
||||||
this.persist();
|
this.persist(sp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,7 +212,7 @@ class StaticPartyServiceClass {
|
||||||
sp.group.resumePositionTicks = 0;
|
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}`);
|
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();
|
this.persist(sp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The group session's current position - saved so the next session
|
/** The group session's current position - saved so the next session
|
||||||
|
|
@ -222,7 +223,7 @@ class StaticPartyServiceClass {
|
||||||
if (!sp || sp.items[sp.group.nextIndex]?.itemId !== itemId) return;
|
if (!sp || sp.items[sp.group.nextIndex]?.itemId !== itemId) return;
|
||||||
if (sp.group.resumePositionTicks === positionTicks) return;
|
if (sp.group.resumePositionTicks === positionTicks) return;
|
||||||
sp.group.resumePositionTicks = positionTicks;
|
sp.group.resumePositionTicks = positionTicks;
|
||||||
this.persist();
|
this.persist(sp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A linked live party ended - forget it as the group's live session. */
|
/** A linked live party ended - forget it as the group's live session. */
|
||||||
|
|
@ -231,7 +232,7 @@ class StaticPartyServiceClass {
|
||||||
if (!sp || sp.livePartyId !== partyId) return;
|
if (!sp || sp.livePartyId !== partyId) return;
|
||||||
sp.livePartyId = null;
|
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`);
|
Logger.staticParty(`session ended id=${sp.id} partyId=${partyId} resumeAt=item ${sp.group.nextIndex + 1} ${Math.round(sp.group.resumePositionTicks / 10000000)}s`);
|
||||||
this.persist();
|
this.persist(sp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Marquer comme vu" - `who` becomes a member if they weren't yet. */
|
/** "Marquer comme vu" - `who` becomes a member if they weren't yet. */
|
||||||
|
|
@ -241,7 +242,7 @@ class StaticPartyServiceClass {
|
||||||
if (!member.watchedItemIds.includes(itemId)) member.watchedItemIds.push(itemId);
|
if (!member.watchedItemIds.includes(itemId)) member.watchedItemIds.push(itemId);
|
||||||
member.lastSeenUtc = Date.now();
|
member.lastSeenUtc = Date.now();
|
||||||
Logger.staticParty(`marked watched id=${sp.id} user=${Logger.who(who.userId, who.userName)} item=${itemId}`);
|
Logger.staticParty(`marked watched id=${sp.id} user=${Logger.who(who.userId, who.userName)} item=${itemId}`);
|
||||||
this.persist();
|
this.persist(sp);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -291,14 +292,16 @@ class StaticPartyServiceClass {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Writes every party to disk (debounced - see StaticPartyStore). */
|
/** Writes `sp`'s own file (debounced - see StaticPartyStore). Only the
|
||||||
private persist(): void {
|
* party that changed is ever rewritten. */
|
||||||
StaticPartyStore.save(() => this.snapshot());
|
private persist(sp: StaticParty): void {
|
||||||
|
StaticPartyStore.save(sp.id, () => this.snapshotOf(sp));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every party, minus runtime-only state. */
|
/** `sp` minus runtime-only state. */
|
||||||
private snapshot(): PersistedStaticParty[] {
|
private snapshotOf(sp: StaticParty): PersistedStaticParty {
|
||||||
return Array.from(this.partiesById.values()).map(({ livePartyId: _live, ...persisted }) => persisted);
|
const { livePartyId: _live, ...persisted } = sp;
|
||||||
|
return persisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** When `sp` last saw anything happen - for list ordering. */
|
/** When `sp` last saw anything happen - for list ordering. */
|
||||||
|
|
|
||||||
|
|
@ -5,121 +5,182 @@ import path from 'path';
|
||||||
import { Logger } from '../Logger';
|
import { Logger } from '../Logger';
|
||||||
import type { PersistedStaticParty } from '../types';
|
import type { PersistedStaticParty } from '../types';
|
||||||
|
|
||||||
/** The on-disk file's own shape - versioned so a future format change can
|
/** One party's own file - versioned so a future format change can migrate
|
||||||
* migrate instead of silently misreading an older file. */
|
* instead of silently misreading an older file. */
|
||||||
interface StaticPartyFile {
|
interface StaticPartyFile {
|
||||||
|
version: 1;
|
||||||
|
party: PersistedStaticParty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first release's single-file format - only read once, to migrate. */
|
||||||
|
interface LegacyStaticPartiesFile {
|
||||||
version: 1;
|
version: 1;
|
||||||
parties: PersistedStaticParty[];
|
parties: PersistedStaticParty[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How long a save waits for more changes before writing - progress
|
/** How long a save waits for more changes to the same party before
|
||||||
* updates arrive in bursts (a whole party's members at once, the 20s
|
* writing - progress updates arrive in bursts (a whole party's members
|
||||||
* position sweep), no reason to rewrite the file for each one. */
|
* at once, the 20s position sweep). */
|
||||||
const SAVE_DEBOUNCE_MS = 2000;
|
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, the first and
|
* The static watch parties' persistence: **one JSON file per party**,
|
||||||
* only thing this app keeps on disk (everything else is deliberately
|
* `${DATA_DIR}/static-parties/<id>.json` - the first and only thing this
|
||||||
* in-memory - see docs/architecture.md's "Party lifecycle"). A JSON file
|
* app keeps on disk (everything else is deliberately in-memory - see
|
||||||
* rather than a database: the data is tiny (a handful of parties, a few
|
* docs/architecture.md's "Party lifecycle").
|
||||||
* hundred item ids each), the runtime image is Node 20 (no built-in
|
|
||||||
* `node:sqlite`), and a native SQLite binding would be the image's first
|
|
||||||
* compiled dependency - not worth it at this size.
|
|
||||||
*
|
*
|
||||||
* Writes are atomic (write a `.tmp` sibling, then rename over the real
|
* One file per party (rather than a single file for all of them) limits
|
||||||
* file) so a crash mid-write never leaves a half-written file behind, and
|
* what any one problem can damage: a write only ever touches the party
|
||||||
* debounced (SAVE_DEBOUNCE_MS). A missing file is a normal first start; an
|
* that changed, and a corrupt file costs that one party, never the rest.
|
||||||
* unreadable/corrupt one is logged and treated as empty - the server must
|
* Each write is also atomic (a `.tmp` sibling, then a rename over the
|
||||||
* never refuse to start over it. The corrupt file is kept aside (`.corrupt`)
|
* real file), so a crash mid-write leaves the previous version intact.
|
||||||
* rather than overwritten by the next save, so nothing is lost for good.
|
*
|
||||||
|
* 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.
|
||||||
*/
|
*/
|
||||||
class StaticPartyStoreClass {
|
class StaticPartyStoreClass {
|
||||||
/** Absolute path of the JSON file - see configure(). */
|
/** `${DATA_DIR}/static-parties` - see configure(). */
|
||||||
private filePath: string | null = null;
|
private dir: string | null = null;
|
||||||
/** The pending debounced save, if any. */
|
/** The first release's single file, migrated on load if still present. */
|
||||||
private saveTimer: NodeJS.Timeout | null = null;
|
private legacyFile: string | null = null;
|
||||||
/** What the pending save will write - always the latest snapshot. */
|
/** Pending debounced saves, by party id. */
|
||||||
private pending: (() => PersistedStaticParty[]) | null = null;
|
private timers = new Map<string, NodeJS.Timeout>();
|
||||||
|
/** What each pending save will write - always the latest snapshot. */
|
||||||
|
private pending = new Map<string, () => PersistedStaticParty>();
|
||||||
|
|
||||||
/** Points the store at `dataDir/static-parties.json`. Must be called
|
/** Points the store at `dataDir`. Must be called before anything else -
|
||||||
* before load()/save() - index.ts does it at startup, tests with a
|
* index.ts does it at startup, tests with a temp directory. */
|
||||||
* temp directory. */
|
|
||||||
public configure(dataDir: string): void {
|
public configure(dataDir: string): void {
|
||||||
this.filePath = path.resolve(dataDir, 'static-parties.json');
|
this.dir = path.resolve(dataDir, 'static-parties');
|
||||||
|
this.legacyFile = path.resolve(dataDir, 'static-parties.json');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads every persisted party - synchronous, startup-only. */
|
/** Reads every persisted party - synchronous, startup-only. */
|
||||||
public load(): PersistedStaticParty[] {
|
public load(): PersistedStaticParty[] {
|
||||||
const file = this.requirePath();
|
const dir = this.requireDir();
|
||||||
let raw: string;
|
|
||||||
try {
|
try {
|
||||||
raw = fs.readFileSync(file, 'utf8');
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
Logger.staticParty(`could not create ${dir} - starting empty: ${(err as Error).message}`);
|
||||||
Logger.staticParty(`no data file yet at ${file} - starting empty`);
|
|
||||||
} else {
|
|
||||||
Logger.staticParty(`could not read ${file} - starting empty: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
this.migrateLegacyFile();
|
||||||
|
|
||||||
|
let names: string[];
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as Partial<StaticPartyFile>;
|
names = fs.readdirSync(dir);
|
||||||
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) {
|
} catch (err) {
|
||||||
Logger.staticParty(`corrupt data file ${file} - moved aside to .corrupt, starting empty: ${(err as Error).message}`);
|
Logger.staticParty(`could not list ${dir} - 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 [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Schedules a debounced write of whatever `snapshot` returns at write
|
const parties: PersistedStaticParty[] = [];
|
||||||
* time (so a burst of changes only ever writes the final state). */
|
for (const name of names) {
|
||||||
public save(snapshot: () => PersistedStaticParty[]): void {
|
const file = path.join(dir, name);
|
||||||
this.pending = snapshot;
|
if (name.endsWith('.tmp')) {
|
||||||
if (this.saveTimer) return;
|
// A write interrupted before its rename - the real file next to it
|
||||||
this.saveTimer = setTimeout(() => {
|
// (if any) is still the last complete version.
|
||||||
this.saveTimer = null;
|
this.tryUnlink(file);
|
||||||
this.writeNow().catch((err: unknown) => {
|
continue;
|
||||||
Logger.staticParty(`save failed: ${(err as Error).message}`);
|
}
|
||||||
|
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}`);
|
||||||
});
|
});
|
||||||
}, SAVE_DEBOUNCE_MS);
|
}, SAVE_DEBOUNCE_MS);
|
||||||
this.saveTimer.unref(); // a pending save must never keep a test process (or a shutdown) alive
|
timer.unref(); // a pending save must never keep a test process (or a shutdown) alive
|
||||||
|
this.timers.set(id, timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Writes any pending save immediately and synchronously - for a
|
/** Deletes `id`'s file - and cancels any save still pending for it, so
|
||||||
* graceful shutdown (SIGTERM/SIGINT), where an async write could be
|
* a debounced write can't recreate a party that was just deleted. */
|
||||||
* cut short by the process exiting. */
|
public remove(id: string): void {
|
||||||
public flushSync(): void {
|
const timer = this.timers.get(id);
|
||||||
if (this.saveTimer) {
|
if (timer) clearTimeout(timer);
|
||||||
clearTimeout(this.saveTimer);
|
this.timers.delete(id);
|
||||||
this.saveTimer = null;
|
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}`);
|
||||||
}
|
}
|
||||||
if (!this.pending) return;
|
}
|
||||||
const file = this.requirePath();
|
}
|
||||||
const snapshot = this.pending;
|
|
||||||
this.pending = null;
|
/** 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. */
|
||||||
|
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);
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
fs.writeFileSync(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
|
fs.writeFileSync(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
|
||||||
fs.renameSync(`${file}.tmp`, file);
|
fs.renameSync(`${file}.tmp`, file);
|
||||||
Logger.staticParty(`flushed to ${file}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Logger.staticParty(`flush failed: ${(err as Error).message}`);
|
Logger.staticParty(`flush failed id=${id}: ${(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. */
|
||||||
|
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 the pending snapshot now - atomically, via a `.tmp` rename. */
|
/** Writes `id`'s pending snapshot - atomically, via a `.tmp` rename. */
|
||||||
public async writeNow(): Promise<void> {
|
private async writeOne(id: string): Promise<void> {
|
||||||
if (!this.pending) return;
|
const snapshot = this.pending.get(id);
|
||||||
const file = this.requirePath();
|
if (!snapshot) return;
|
||||||
const snapshot = this.pending;
|
this.pending.delete(id);
|
||||||
this.pending = null;
|
const file = this.fileFor(id);
|
||||||
try {
|
try {
|
||||||
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
||||||
await fs.promises.writeFile(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
|
await fs.promises.writeFile(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
|
||||||
|
|
@ -130,16 +191,81 @@ class StaticPartyStoreClass {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The file's JSON text for `parties`. */
|
/** One party file's content, or null (moved aside) if it's unreadable. */
|
||||||
private serialize(parties: PersistedStaticParty[]): string {
|
private readPartyFile(file: string): PersistedStaticParty | null {
|
||||||
const data: StaticPartyFile = { version: 1, parties };
|
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 };
|
||||||
return JSON.stringify(data, null, 2);
|
return JSON.stringify(data, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The configured file path - throws if configure() was never called. */
|
/** Best-effort delete - logs anything but "already gone". */
|
||||||
private requirePath(): string {
|
private tryUnlink(file: string): void {
|
||||||
if (!this.filePath) throw new Error('StaticPartyStore.configure() was never called');
|
try {
|
||||||
return this.filePath;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,12 @@ before(() => {
|
||||||
StaticPartyStore.configure(dataDir);
|
StaticPartyStore.configure(dataDir);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const partiesDir = () => path.join(dataDir, 'static-parties');
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
StaticPartyStore.flushSync(); // no debounced save from a previous test may land mid-test
|
StaticPartyStore.flushSync(); // no debounced save from a previous test may land mid-test
|
||||||
fs.rmSync(path.join(dataDir, 'static-parties.json'), { force: true });
|
fs.rmSync(partiesDir(), { recursive: true, force: true });
|
||||||
fs.rmSync(path.join(dataDir, 'static-parties.json.corrupt'), { force: true });
|
for (const name of fs.readdirSync(dataDir)) fs.rmSync(path.join(dataDir, name), { recursive: true, force: true });
|
||||||
StaticPartyService.init();
|
StaticPartyService.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -166,31 +168,90 @@ describe('StaticPartyService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('StaticPartyStore', () => {
|
describe('StaticPartyStore', () => {
|
||||||
it('round-trips every party through the JSON file, without runtime-only state', async () => {
|
it('writes one file per party, and round-trips it without runtime-only state', async () => {
|
||||||
const sp = StaticPartyService.create(alice, { type: 'playlist', sourceId: 'pl1' }, 'Soirées', '/img/pl1', ITEMS);
|
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.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
|
||||||
StaticPartyService.setLiveParty(sp, 'live-1');
|
StaticPartyService.setLiveParty(sp, 'live-1');
|
||||||
await StaticPartyStore.writeNow();
|
await StaticPartyStore.writeNow();
|
||||||
|
|
||||||
const onDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'static-parties.json'), 'utf8'));
|
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'));
|
||||||
assert.equal(onDisk.version, 1);
|
assert.equal(onDisk.version, 1);
|
||||||
assert.equal('livePartyId' in onDisk.parties[0], false);
|
assert.equal('livePartyId' in onDisk.party, false);
|
||||||
|
|
||||||
StaticPartyService.init();
|
StaticPartyService.init();
|
||||||
const reloaded = StaticPartyService.getById(sp.id)!;
|
const reloaded = StaticPartyService.getById(sp.id)!;
|
||||||
assert.equal(reloaded.name, 'Soirées');
|
assert.equal(reloaded.name, 'Soirées');
|
||||||
assert.deepEqual(reloaded.group.completedItemIds, ['e1']);
|
assert.deepEqual(reloaded.group.completedItemIds, ['e1']);
|
||||||
assert.equal(reloaded.livePartyId, null, 'a restart ends every live session');
|
assert.equal(reloaded.livePartyId, null, 'a restart ends every live session');
|
||||||
|
assert.ok(StaticPartyService.getById(other.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a corrupt file is moved aside and the service starts empty instead of crashing', () => {
|
it('a change only rewrites the party that changed', async () => {
|
||||||
fs.writeFileSync(path.join(dataDir, 'static-parties.json'), '{ not json', 'utf8');
|
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');
|
||||||
|
|
||||||
StaticPartyService.init();
|
StaticPartyService.init();
|
||||||
assert.equal(StaticPartyService.list().length, 0);
|
|
||||||
assert.ok(fs.existsSync(path.join(dataDir, 'static-parties.json.corrupt')));
|
assert.deepEqual(StaticPartyService.list().map((p) => p.id), [good.id]);
|
||||||
|
assert.ok(fs.existsSync(path.join(partiesDir(), 'broken-id.json.corrupt')));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a missing file is a normal first start', () => {
|
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', () => {
|
||||||
StaticPartyService.init();
|
StaticPartyService.init();
|
||||||
assert.equal(StaticPartyService.list().length, 0);
|
assert.equal(StaticPartyService.list().length, 0);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -85,14 +85,24 @@ des watch parties en cours.
|
||||||
|
|
||||||
## Persistance
|
## Persistance
|
||||||
|
|
||||||
- `StaticPartyStore` écrit un fichier JSON unique :
|
- `StaticPartyStore` écrit **un fichier JSON par watch party** :
|
||||||
`${DATA_DIR}/static-parties.json`.
|
`${DATA_DIR}/static-parties/<id>.json`, au format `{version: 1, party}`.
|
||||||
- `DATA_DIR` vaut `./data` par défaut, et `/data` dans l'image Docker.
|
- `DATA_DIR` vaut `./data` par défaut, et `/data` dans l'image Docker.
|
||||||
`docker-compose.yml` y monte `./data`.
|
`docker-compose.yml` y monte `./data`.
|
||||||
- L'écriture est atomique (`.tmp` puis `rename`) et debouncée (2 s).
|
- Un fichier par party limite les dégâts : une écriture ne touche que la
|
||||||
Elle est flushée de façon synchrone sur `SIGTERM`/`SIGINT`.
|
party modifiée, et un fichier corrompu ne coûte que cette party-là.
|
||||||
- Un fichier corrompu est renommé en `.corrupt` et le serveur démarre à
|
- L'écriture est atomique (`.tmp` puis `rename`) et debouncée (2 s) party
|
||||||
vide ; il ne crashe jamais pour ça.
|
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é).
|
||||||
- Pas de SQLite : l'image tourne en Node 20 (pas de `node:sqlite`), une
|
- 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
|
dépendance native serait une première dans l'image, et les données sont
|
||||||
minuscules.
|
minuscules.
|
||||||
|
|
@ -111,11 +121,13 @@ des watch parties en cours.
|
||||||
- la liste complète des items.
|
- la liste complète des items.
|
||||||
- Bouton « Créer une watch party permanente » sur `views/series` et sur
|
- Bouton « Créer une watch party permanente » sur `views/series` et sur
|
||||||
`views/playlist` (vidéo uniquement).
|
`views/playlist` (vidéo uniquement).
|
||||||
- Accueil :
|
- Onglet dédié « Parties permanentes » (sidebar et menu mobile), route
|
||||||
- une section « Watch parties permanentes », avec la progression, un badge
|
`/static` (`views/staticParties/`) : une carte par watch party avec la
|
||||||
« N manqués » et une pastille « séance en cours » ;
|
progression, le prochain élément, un badge « N manqués » et une pastille
|
||||||
- les séances live affichent un lien `↻ nom` vers leur watch party
|
« séance en cours ». L'onglet reste actif sur la page d'une party.
|
||||||
statique.
|
- Séparation avec les watch parties classiques : la liste « Watch parties en
|
||||||
|
cours » de l'accueil exclut les séances de watch party permanente
|
||||||
|
(`staticPartyId` non nul) ; elles ne s'affichent que dans leur onglet.
|
||||||
|
|
||||||
## Limites connues
|
## Limites connues
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue