diff --git a/client/src/app.ts b/client/src/app.ts index 442c155..35351e1 100644 --- a/client/src/app.ts +++ b/client/src/app.ts @@ -11,6 +11,7 @@ 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); @@ -21,6 +22,7 @@ route('/artist/:id', renderArtist); route('/album/:id', renderAlbum); route('/playlist/:id', renderPlaylist); route('/party/:id', renderPartyPlayer); +route('/static', renderStaticParties); route('/static/:id', renderStaticParty); /** diff --git a/client/src/shell/Shell.html b/client/src/shell/Shell.html index df0fa01..8e38510 100644 --- a/client/src/shell/Shell.html +++ b/client/src/shell/Shell.html @@ -3,6 +3,7 @@
Watch Party
@@ -41,6 +42,7 @@ plain scrim tap-only close used to leave unsolved). -->
Accueil + Lounge parties
diff --git a/client/src/shell/Shell.ts b/client/src/shell/Shell.ts index 60f76cb..2a5d793 100644 --- a/client/src/shell/Shell.ts +++ b/client/src/shell/Shell.ts @@ -18,6 +18,8 @@ const SHOWS_ICON_SVG = ' { 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. @@ -98,8 +101,11 @@ export async function renderShell(appEl: HTMLElement): Promise { const highlight = () => { appEl.querySelectorAll('.navItem').forEach((a) => { - const isHome = a.getAttribute('href') === '#/home'; - a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash)); + 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); }); }; highlight(); diff --git a/client/src/tiles.ts b/client/src/tiles.ts index 7ac8233..5706d04 100644 --- a/client/src/tiles.ts +++ b/client/src/tiles.ts @@ -61,7 +61,9 @@ const DRILL_DOWN: Record = { function posterTileHtml(item: ItemDto): string { const drill = item.type ? DRILL_DOWN[item.type] : undefined; const drillHref = drill ? `#/${drill.route}/${item.id}` : null; - const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party'; + // 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'; return `
${drillHref ? `` : ''} @@ -116,7 +118,7 @@ export function tileHtml(item: ItemDto): string { } /** - * Wires up every "Lancer une watch party" button inside `container`, now + * Wires up every "Lancer une watch/listen 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. @@ -132,6 +134,7 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string) const btn = (e.target as HTMLElement).closest('.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 { @@ -139,7 +142,7 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string) } catch (err) { showToast((err as Error).message, { variant: 'error' }); btn.disabled = false; - btn.textContent = 'Lancer une watch party'; + btn.textContent = idleLabel; } }); } diff --git a/client/src/views/album/Album.ts b/client/src/views/album/Album.ts index 9a942cc..2b4c120 100644 --- a/client/src/views/album/Album.ts +++ b/client/src/views/album/Album.ts @@ -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 = 'Écouter ensemble'; + const startLabel = 'Lancer une listen party'; const addLabel = 'Ajouter à la file'; const startAlbumLabel = 'Écouter cet album'; const addAlbumLabel = "Ajouter l'album à la file"; diff --git a/client/src/views/home/Home.css b/client/src/views/home/Home.css index c1fd437..86d6e66 100644 --- a/client/src/views/home/Home.css +++ b/client/src/views/home/Home.css @@ -61,40 +61,17 @@ 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; +/* 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; } -.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; } +.partyKind--video { color: var(--accent-bright); background: rgba(82, 181, 75, .15); } +.partyKind--music { color: #7fb4ff; background: rgba(127, 180, 255, .15); } diff --git a/client/src/views/home/Home.html b/client/src/views/home/Home.html index de9b9b6..801c159 100644 --- a/client/src/views/home/Home.html +++ b/client/src/views/home/Home.html @@ -3,13 +3,8 @@
- -
diff --git a/client/src/views/home/Home.ts b/client/src/views/home/Home.ts index 3aac85f..b9eae77 100644 --- a/client/src/views/home/Home.ts +++ b/client/src/views/home/Home.ts @@ -7,8 +7,7 @@ import { navigate } from '../../router'; import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles'; import { LOADING_HINT } from '../../loading'; import { showToast } from '../../toast'; -import { listStaticParties } from '../../api/staticParty'; -import type { LobbyPartyDto, StaticPartySummaryDto } from '../../types'; +import type { LobbyPartyDto } from '../../types'; export function renderHome(root: HTMLElement): () => void { root.innerHTML = html; @@ -19,20 +18,9 @@ export function renderHome(root: HTMLElement): () => void { // label is the one piece that ticks purely client-side (from // createdUtc) - no point re-pushing from the server once a second just // for a clock. - // Static watch parties are a REST fetch (their data changes rarely), but - // re-fetched whenever the set of live static-party sessions in the lobby - // changes - a session starting/ending is what moves their cards' - // "en cours"/progress state. Not on every lobby snapshot: those also - // fire on every pause/join, and each static-party fetch reads Emby. - let staticSessionsKey: string | null = null; - const lobby = connectLobby((parties) => { - renderParties(root, parties); - const key = parties.map((p) => p.staticPartyId).filter(Boolean).sort().join(','); - if (key !== staticSessionsKey) { - staticSessionsKey = key; - loadStaticParties(root); - } - }); + // 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))); const elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000); loadRecentSections(root); @@ -71,8 +59,8 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
+ ${p.mediaType === 'music' ? 'Listen party' : 'Watch party'} ${escapeHtml(p.itemName || '')} - ${p.staticPartyId ? `↻ ${escapeHtml(p.staticPartyName || '')}` : ''} ${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''} ·
diff --git a/client/src/views/series/Series.html b/client/src/views/series/Series.html index ab5e41a..d219701 100644 --- a/client/src/views/series/Series.html +++ b/client/src/views/series/Series.html @@ -5,7 +5,7 @@

- + Voir sur Emby
diff --git a/client/src/views/staticParties/StaticParties.css b/client/src/views/staticParties/StaticParties.css new file mode 100644 index 0000000..3e98f3e --- /dev/null +++ b/client/src/views/staticParties/StaticParties.css @@ -0,0 +1,81 @@ +/* ---- "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; } +} diff --git a/client/src/views/staticParties/StaticParties.html b/client/src/views/staticParties/StaticParties.html new file mode 100644 index 0000000..44b759d --- /dev/null +++ b/client/src/views/staticParties/StaticParties.html @@ -0,0 +1,9 @@ + +

+ 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 ». +

+
diff --git a/client/src/views/staticParties/StaticParties.ts b/client/src/views/staticParties/StaticParties.ts new file mode 100644 index 0000000..09dae4e --- /dev/null +++ b/client/src/views/staticParties/StaticParties.ts @@ -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 "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 = `

${escapeHtml((err as Error).message)}

`; + }); +} + +/** 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 = '

Aucune lounge party pour le moment.

'; + return; + } + grid.innerHTML = parties.map((p) => { + const percent = p.itemCount ? Math.round((p.groupWatchedCount / p.itemCount) * 100) : 0; + return ` + + +
+ ${escapeHtml(p.name)} + ${p.sourceType === 'series' ? 'Série' : 'Playlist'} · ${p.groupWatchedCount}/${p.itemCount} vus · ${p.memberCount} membre${p.memberCount > 1 ? 's' : ''} +
+ ${p.livePartyId + ? '● Séance en cours' + : `${p.nextItemName ? `Prochain : ${escapeHtml(p.nextItemName)}` : 'Le groupe a tout vu.'}`} +
+ ${p.missedCount ? `${p.missedCount} manqué${p.missedCount > 1 ? 's' : ''}` : ''} +
`; + }).join(''); +} diff --git a/client/src/views/staticParty/StaticParty.html b/client/src/views/staticParty/StaticParty.html index 85ae4b5..4aa5cf0 100644 --- a/client/src/views/staticParty/StaticParty.html +++ b/client/src/views/staticParty/StaticParty.html @@ -2,14 +2,14 @@
- Watch party permanente + Lounge party

- +
diff --git a/client/src/views/staticParty/StaticParty.ts b/client/src/views/staticParty/StaticParty.ts index 5d4ce71..365b4e4 100644 --- a/client/src/views/staticParty/StaticParty.ts +++ b/client/src/views/staticParty/StaticParty.ts @@ -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 = `

${escapeHtml((err as Error).message || 'Impossible de charger cette watch party.')}

`; + root.innerHTML = `

${escapeHtml((err as Error).message || 'Impossible de charger cette lounge party.')}

`; 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 watch party.'); + showToast('Tu fais maintenant partie de cette lounge party.'); await reload(root, staticId); }); }); const leaveBtn = root.querySelector('#leaveBtn') as HTMLButtonElement; leaveBtn.addEventListener('click', () => { - if (!confirm('Quitter cette watch party ? Ta progression y sera oubliée.')) return; + if (!confirm('Quitter cette lounge 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 watch party pour tout le monde ? La progression de chacun sera perdue.')) return; + if (!confirm('Supprimer cette lounge party pour tout le monde ? La progression de chacun sera perdue.')) return; runBusy(deleteBtn, async () => { await deleteStaticParty(staticId); - showToast('Watch party supprimée.'); - navigate('/home'); + showToast('Lounge party supprimée.'); + navigate('/static'); }); }); diff --git a/client/src/views/staticParty/createStaticButton.ts b/client/src/views/staticParty/createStaticButton.ts index 3e38a9b..4524f69 100644 --- a/client/src/views/staticParty/createStaticButton.ts +++ b/client/src/views/staticParty/createStaticButton.ts @@ -3,7 +3,7 @@ import { navigate } from '../../router'; import { showToast } from '../../toast'; /** - * "Créer une watch party permanente" - shared by views/series and + * "Créer une lounge party" - 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. diff --git a/docs/architecture.md b/docs/architecture.md index 3353a57..efe9d77 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4576,10 +4576,19 @@ 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.** A JSON file - (`StaticPartyStore`, `DATA_DIR`) that is atomic, debounced, and - flushed on SIGTERM. "Party lifecycle" above still holds for ordinary - parties. +- **This app's first on-disk state.** One JSON file per static party + (`StaticPartyStore`, `${DATA_DIR}/static-parties/.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. - **"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 diff --git a/server/src/index.ts b/server/src/index.ts index 18d6666..43a3fe1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 watch party." }); + res.status(403).json({ error: "Tu n'es pas membre de cette 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 watch party." }); + res.status(403).json({ error: "Tu n'es pas membre de cette 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 watch party." }); + res.status(403).json({ error: "Tu n'es pas membre de cette 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 watch party." }); + res.status(403).json({ error: "Tu n'es pas membre de cette 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: 'Watch party musique introuvable.' }); + res.status(404).json({ error: 'Listen party introuvable.' }); return; } const session = req.embySession!; if (!party.members.has(session.userId)) { - res.status(403).json({ error: "Tu n'es pas membre de cette watch party." }); + res.status(403).json({ error: "Tu n'es pas membre de cette 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: 'Watch party musique introuvable.' }); + res.status(404).json({ error: 'Listen party introuvable.' }); return; } const session = req.embySession!; if (!party.members.has(session.userId)) { - res.status(403).json({ error: "Tu n'es pas membre de cette watch party." }); + res.status(403).json({ error: "Tu n'es pas membre de cette 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 watch party." }); + res.status(403).json({ error: "Tu n'es pas membre de cette 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: 'Watch party permanente introuvable.' }); + if (!sp) res.status(404).json({ error: 'Lounge party 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 watch party permanente se crée depuis une série ou une playlist vidéo.' }); + res.status(400).json({ error: 'Une lounge party 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 watch party peut la supprimer.' }); + res.status(403).json({ error: 'Seule la personne qui a créé cette lounge 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 watch party.' }); + res.status(404).json({ error: 'Cet élément ne fait pas partie de cette lounge 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 watch party.' }); + res.status(409).json({ error: 'Le groupe a déjà tout vu dans cette lounge party.' }); return; } const first = queue[0]!; diff --git a/server/src/services/StaticPartyService.ts b/server/src/services/StaticPartyService.ts index 875f90d..948f347 100644 --- a/server/src/services/StaticPartyService.ts +++ b/server/src/services/StaticPartyService.ts @@ -70,7 +70,8 @@ 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. + * Persisted through StaticPartyStore after every change - one file per + * party, so only the party that changed is ever rewritten. */ class StaticPartyServiceClass { /** Every static party, by id. */ @@ -118,7 +119,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(); + this.persist(sp); return sp; } @@ -128,13 +129,13 @@ class StaticPartyServiceClass { if (existing) { existing.userName = who.userName; existing.lastSeenUtc = Date.now(); - this.persist(); + this.persist(sp); 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(); + this.persist(sp); return member; } @@ -145,7 +146,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(); + this.persist(sp); } /** Deletes the party for good - creator only. Returns false (and does @@ -157,7 +158,7 @@ class StaticPartyServiceClass { } this.partiesById.delete(sp.id); Logger.staticParty(`deleted id=${sp.id} name="${sp.name}"`); - this.persist(); + StaticPartyStore.remove(sp.id); return true; } @@ -169,7 +170,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(); + if (changed) this.persist(sp); } /** What the group's next session plays: everything from the group's @@ -184,7 +185,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(); + this.persist(sp); } } @@ -211,7 +212,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(); + this.persist(sp); } /** 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.group.resumePositionTicks === positionTicks) return; sp.group.resumePositionTicks = positionTicks; - this.persist(); + this.persist(sp); } /** 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; 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(); + this.persist(sp); } /** "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); member.lastSeenUtc = Date.now(); Logger.staticParty(`marked watched id=${sp.id} user=${Logger.who(who.userId, who.userName)} item=${itemId}`); - this.persist(); + this.persist(sp); return true; } @@ -291,14 +292,16 @@ class StaticPartyServiceClass { }; } - /** Writes every party to disk (debounced - see StaticPartyStore). */ - private persist(): void { - StaticPartyStore.save(() => this.snapshot()); + /** 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)); } - /** Every party, minus runtime-only state. */ - private snapshot(): PersistedStaticParty[] { - return Array.from(this.partiesById.values()).map(({ livePartyId: _live, ...persisted }) => persisted); + /** `sp` minus runtime-only state. */ + private snapshotOf(sp: StaticParty): PersistedStaticParty { + const { livePartyId: _live, ...persisted } = sp; + return persisted; } /** When `sp` last saw anything happen - for list ordering. */ diff --git a/server/src/services/StaticPartyStore.ts b/server/src/services/StaticPartyStore.ts index 8596f96..6c7be03 100644 --- a/server/src/services/StaticPartyStore.ts +++ b/server/src/services/StaticPartyStore.ts @@ -5,121 +5,182 @@ import path from 'path'; import { Logger } from '../Logger'; import type { PersistedStaticParty } from '../types'; -/** The on-disk file's own shape - versioned so a future format change can - * migrate instead of silently misreading an older file. */ +/** One party's own file - 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 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. */ +/** 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). */ 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 - * 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. + * The static watch parties' persistence: **one JSON file per party**, + * `${DATA_DIR}/static-parties/.json` - the first and only thing this + * app keeps on disk (everything else is deliberately in-memory - see + * docs/architecture.md's "Party lifecycle"). * - * 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. + * 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. */ class StaticPartyStoreClass { - /** Absolute path of the JSON file - see configure(). */ - private filePath: string | null = null; - /** The pending debounced save, if any. */ - private saveTimer: NodeJS.Timeout | null = null; - /** What the pending save will write - always the latest snapshot. */ - private pending: (() => PersistedStaticParty[]) | null = null; + /** `${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(); + /** What each pending save will write - always the latest snapshot. */ + private pending = new Map PersistedStaticParty>(); - /** 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. */ + /** Points the store at `dataDir`. Must be called before anything else - + * index.ts does it at startup, tests with a temp directory. */ 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. */ public load(): PersistedStaticParty[] { - const file = this.requirePath(); - let raw: string; + const dir = this.requireDir(); try { - raw = fs.readFileSync(file, 'utf8'); + fs.mkdirSync(dir, { recursive: true }); } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - Logger.staticParty(`no data file yet at ${file} - starting empty`); - } else { - Logger.staticParty(`could not read ${file} - starting empty: ${(err as Error).message}`); - } + Logger.staticParty(`could not create ${dir} - starting empty: ${(err as Error).message}`); return []; } + this.migrateLegacyFile(); + + let names: string[]; try { - const parsed = JSON.parse(raw) as Partial; - 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; + names = fs.readdirSync(dir); } catch (err) { - Logger.staticParty(`corrupt data file ${file} - moved aside to .corrupt, starting empty: ${(err as Error).message}`); - try { - fs.renameSync(file, `${file}.corrupt`); - } catch (renameErr) { - Logger.staticParty(`could not move corrupt file aside: ${(renameErr as Error).message}`); - } + Logger.staticParty(`could not list ${dir} - starting empty: ${(err 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 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}`); + /** 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); - 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 - * graceful shutdown (SIGTERM/SIGINT), where an async write could be - * cut short by the process exiting. */ - public flushSync(): void { - if (this.saveTimer) { - clearTimeout(this.saveTimer); - this.saveTimer = null; - } - if (!this.pending) return; - const file = this.requirePath(); - const snapshot = this.pending; - this.pending = null; + /** 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.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(`${file}.tmp`, this.serialize(snapshot()), 'utf8'); - fs.renameSync(`${file}.tmp`, file); - Logger.staticParty(`flushed to ${file}`); + fs.unlinkSync(file); + Logger.staticParty(`removed ${file}`); } catch (err) { - Logger.staticParty(`flush failed: ${(err as Error).message}`); + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + Logger.staticParty(`could not remove ${file}: ${(err as Error).message}`); + } } } - /** Writes the pending snapshot now - atomically, via a `.tmp` rename. */ + /** 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 { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(`${file}.tmp`, this.serialize(snapshot()), 'utf8'); + fs.renameSync(`${file}.tmp`, file); + } catch (err) { + 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 { - if (!this.pending) return; - const file = this.requirePath(); - const snapshot = this.pending; - this.pending = null; + 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 { + const snapshot = this.pending.get(id); + if (!snapshot) return; + this.pending.delete(id); + const file = this.fileFor(id); try { await fs.promises.mkdir(path.dirname(file), { recursive: true }); await fs.promises.writeFile(`${file}.tmp`, this.serialize(snapshot()), 'utf8'); @@ -130,16 +191,81 @@ class StaticPartyStoreClass { } } - /** The file's JSON text for `parties`. */ - private serialize(parties: PersistedStaticParty[]): string { - const data: StaticPartyFile = { version: 1, parties }; + /** 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; + 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; + 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); } - /** 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; + /** 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; } } diff --git a/server/test/staticPartyService.test.ts b/server/test/staticPartyService.test.ts index 644d6c9..98c1ca8 100644 --- a/server/test/staticPartyService.test.ts +++ b/server/test/staticPartyService.test.ts @@ -22,10 +22,12 @@ 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(path.join(dataDir, 'static-parties.json'), { force: true }); - fs.rmSync(path.join(dataDir, 'static-parties.json.corrupt'), { force: true }); + fs.rmSync(partiesDir(), { recursive: true, force: true }); + for (const name of fs.readdirSync(dataDir)) fs.rmSync(path.join(dataDir, name), { recursive: true, force: true }); StaticPartyService.init(); }); @@ -166,31 +168,90 @@ describe('StaticPartyService', () => { }); 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 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(); - 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('livePartyId' in onDisk.parties[0], false); + assert.equal('livePartyId' in onDisk.party, 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 corrupt file is moved aside and the service starts empty instead of crashing', () => { - fs.writeFileSync(path.join(dataDir, 'static-parties.json'), '{ not json', 'utf8'); + 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'); + 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(); assert.equal(StaticPartyService.list().length, 0); }); diff --git a/spec/static-watch-party.md b/spec/static-watch-party.md index eb37ae4..116c209 100644 --- a/spec/static-watch-party.md +++ b/spec/static-watch-party.md @@ -1,4 +1,16 @@ -# Watch party statique (permanente) +# 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 ». ## Objectif @@ -85,14 +97,24 @@ des watch parties en cours. ## Persistance -- `StaticPartyStore` écrit un fichier JSON unique : - `${DATA_DIR}/static-parties.json`. +- `StaticPartyStore` écrit **un fichier JSON par watch party** : + `${DATA_DIR}/static-parties/.json`, au format `{version: 1, party}`. - `DATA_DIR` vaut `./data` par défaut, et `/data` dans l'image Docker. `docker-compose.yml` y monte `./data`. - - L'écriture est atomique (`.tmp` puis `rename`) et debouncée (2 s). - Elle est flushée de façon synchrone sur `SIGTERM`/`SIGINT`. - - Un fichier corrompu est renommé en `.corrupt` et le serveur démarre à - vide ; il ne crashe jamais pour ça. + - 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é). - 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. @@ -109,13 +131,15 @@ 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 watch party permanente » sur `views/series` et sur +- Bouton « Créer une lounge party » sur `views/series` et sur `views/playlist` (vidéo uniquement). -- Accueil : - - une section « Watch parties permanentes », avec la progression, un badge - « N manqués » et une pastille « séance en cours » ; - - les séances live affichent un lien `↻ nom` vers leur watch party - statique. +- 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. ## Limites connues