watchparty/spec/static-watch-party.md
Nicolas 1e437e4a91
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
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>
2026-09-24 19:16:55 +02:00

140 lines
7.1 KiB
Markdown

# Watch party statique (permanente)
## Objectif
Suivre une série entière, ou une playlist vidéo, en groupe sur plusieurs
soirées. L'app mémorise :
- **l'avancement du groupe** : les éléments terminés ensemble, et où la
dernière séance s'est arrêtée ;
- **l'avancement de chaque personne** : ce qu'elle a vu.
Une personne qui revient ou qui rejoint en cours de route voit la liste de
ce qu'elle a **manqué** et peut le rattraper.
Contrairement à une watch party classique, qui vit en mémoire et disparaît
avec son dernier membre, une watch party statique est **persistée sur
disque** et survit aux redémarrages.
## Modèle
| Entité | Où | Rôle |
|---|---|---|
| `StaticParty` | `server/src/types.ts`, persistée | la « saison » du groupe : items, progression du groupe, membres |
| `StaticPartyMember` | dans `StaticParty.members` | `watchedItemIds` = ce que la personne a vu dans l'app |
| Séance | une `Party` live ordinaire + `party.staticLink` | la lecture elle-même : lecteur, sync, flux partagé et lobby réutilisés tels quels |
`party.staticLink.mode` :
- `group` : la séance du groupe. Un item terminé fait avancer le groupe
**et** est crédité à chaque participant connecté.
- `catchUp` : un rattrapage. L'item est crédité aux participants connectés
uniquement, le groupe ne bouge pas.
`party.hiddenFromLobby` : un rattrapage en solo n'apparaît pas dans la liste
des watch parties en cours.
## Règles
- **Item terminé** :
- l'hôte passe à l'item suivant (`next`),
- ou le dernier item de la file se termine (`itemEnded`, nouvel event socket réservé à l'hôte).
Les deux passent par `PartyService.completeCurrentItem`, qui gère aussi le
flag `Played` d'Emby. Un même item n'est jamais compté deux fois de suite
(`lastCompletedItemId`). C'est la même heuristique que
`PlaystateService.markPlayed` : « le groupe est passé à la suite » vaut
« vu ».
- **Seuls les membres connectés** au moment où l'item se termine sont
crédités. Une personne partie en cours d'épisode l'a donc manqué ; elle
peut le marquer comme vu.
- **Manqués d'une personne** : les items terminés par le groupe, dans
l'ordre de la liste, moins ceux qu'elle a vus dans l'app
(`watchedItemIds`), moins ceux qu'Emby a déjà en `Played` pour son compte
(vus ailleurs). C'est la fonction pure `computeMissedIds`.
- **Reprise** : le sweep existant des 20 s enregistre la position de l'hôte
(`group.resumePositionTicks`). La séance suivante démarre avec cette
position comme position de l'hôte, que `Player.ts` utilise déjà pour
placer la première image.
- **Nouveaux épisodes** : chaque lecture de la page, et chaque lancement de
séance, recharge la source. Les nouveaux items sont ajoutés à la fin ; les
items retirés de la source sont conservés dans l'historique
(`mergeItemLists`).
- **Accès** : ouverte à tous les comptes Emby.
- Rejoindre = devenir membre. Rejoindre la séance du groupe depuis le
lobby rend aussi membre de la watch party statique.
- N'importe quel membre peut lancer la séance ; celui qui la lance en
devient l'hôte.
- Quitter efface sa propre progression.
- Seul le créateur peut supprimer la watch party.
- **Contenus** : séries et playlists vidéo uniquement. Une playlist musicale
est refusée.
## API (`server/src/index.ts`)
| Méthode | Route | Rôle |
|---|---|---|
| GET | `/api/static-parties` | liste, avec le nombre de manqués de l'utilisateur et la séance en cours |
| POST | `/api/static-parties` | `{sourceId, name?}` : série, épisode (sa série) ou playlist |
| GET | `/api/static-parties/:id` | détail : items + état groupe / utilisateur, membres, `missedItemIds` |
| POST | `/:id/join`, `/:id/leave` | devenir membre / quitter |
| DELETE | `/:id` | supprimer (créateur uniquement) |
| POST | `/:id/session` | lancer la séance du groupe, ou rejoindre celle en cours, puis renvoyer `{partyId}` |
| POST | `/:id/items/:itemId/watched` | marquer comme vu |
| POST | `/:id/catch-up` | `{itemIds, mode: 'solo'\|'party'}`, renvoie `{partyId}` |
## Persistance
- `StaticPartyStore` écrit **un fichier JSON par watch party** :
`${DATA_DIR}/static-parties/<id>.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`.
- 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.
- `livePartyId` n'est jamais persisté : un redémarrage termine toutes les
séances, et la reprise se fait depuis la position enregistrée.
## Client
- `views/staticParty/` (route `/static/:id`) contient :
- l'en-tête, avec la progression du groupe et le bouton lancer / rejoindre
la séance ;
- « Tu as manqué », avec pour chaque élément cases à cocher, *Regarder en
solo* et *Marquer comme vu*, plus deux actions groupées : rattraper en
solo, ou lancer une watch party de rattrapage ;
- les membres ;
- la liste complète des items.
- Bouton « Créer une watch party permanente » sur `views/series` et sur
`views/playlist` (vidéo uniquement).
- Onglet dédié « Parties permanentes » (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 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
- « Vu » reste une heuristique : passer à la suite compte comme vu, comme
pour le flag `Played` d'Emby. Il n'y a pas de seuil de pourcentage.
- La liste et le détail relisent la source Emby à chaque affichage (une
requête par watch party pour la liste). C'est acceptable à l'échelle d'un
serveur perso ; à mettre en cache si la liste grossit.
- Plusieurs watch parties statiques peuvent exister sur la même série
(des groupes différents) : c'est voulu, sans déduplication.