Pivot to a standalone app - Emby plugin hit a hard wall

No plugin API exists for adding a button to Emby's player or item
detail page (confirmed by an Emby developer on their own forum, and
by research since). The only way to reach that would be patching
Emby's shipped web client files directly - outside the plugin,
breaks on every image update, needs manual reapplication. Not worth
the ongoing cost, per discussion.

New direction: a standalone app with its own player. Reads Emby's
catalog/streams directly from the browser (no plugin, no menu
integration fights); a small Node backend only coordinates party
membership and relays playback state over WebSocket - it never
touches a media byte. Auth is Emby-only, as chosen: the browser signs
in against Emby directly and this backend validates whatever token it
gets handed by asking Emby itself (GET /Users/{userId}), never seeing
a password.

Control model changed too: now that we own the player, host controls
playback and everyone else follows (event-driven play/pause/seek +
a 3s heartbeat, drift-corrected on the follower side) - the old
plugin's "everyone keeps their own controls, shared reference
position" was specifically a concession to nudging pre-existing Emby
app sessions we didn't control, which no longer applies.

- server/: Express + ws backend (party lifecycle, WebSocket protocol,
  Emby token validation)
- client/: plain-JS/ES-modules frontend (login, search, item detail,
  party player) - no build step
- Smoke-tested locally end to end against a stubbed Emby URL: server
  starts, routing/auth-gating works, error handling degrades
  gracefully (network failure, invalid token) - see the session
  transcript for the actual browser test run
- docs/architecture.md rewritten around the new design, including why
  the plugin was abandoned and a fresh "assumptions to verify" list
  (CORS between this app's origin and Emby's is the biggest unknown)
- src/WatchParty/ (the Emby plugin) kept as-is for reference, no
  longer the active direction

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-13 20:48:55 +02:00
parent f25f719e5f
commit 6ff9aa29e2
24 changed files with 2200 additions and 333 deletions

11
.claude/launch.json Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "watchparty-server",
"runtimeExecutable": "node",
"runtimeArgs": ["server/src/index.js"],
"port": 3000
}
]
}

4
.gitignore vendored
View file

@ -9,8 +9,10 @@ obj/
.idea/ .idea/
*.swp *.swp
## Node (only relevant if control-panel tooling is ever added) ## Node (server/)
node_modules/ node_modules/
.env
.env.local
## OS cruft ## OS cruft
.DS_Store .DS_Store

129
README.md
View file

@ -1,97 +1,94 @@
# Watch Party for Emby # Watch Party for Emby
An Emby Server plugin that lets a group of users on the same server watch the A standalone web app for watching the same title, in sync, with other users
same title together. Nobody switches players: everyone keeps using their of the same Emby server. It reads your Emby catalog and streams your Emby
normal Emby app (web, mobile, TV, whatever), and the plugin nudges playback media, but it's its own app with its own player — not an Emby plugin.
back in sync using the same remote-control commands Emby already uses for
casting / "play on another device".
See [docs/architecture.md](docs/architecture.md) for the full design, See [docs/architecture.md](docs/architecture.md) for the full design,
including which parts are solidly confirmed against Emby's SDK docs and which including why this replaced an earlier Emby-plugin attempt (still in
still need verification against your exact server build. [src/WatchParty/](src/WatchParty/) for reference — see below).
## How it's meant to work ## How it works
1. You start playing a title in your normal Emby app. 1. Sign in with your Emby account.
2. In Emby's web client (or the Electron desktop app), open **Watch Party** 2. Search your Emby catalog for a title.
from the menu and start a party. You get a 6-character join code. No 3. Start a party (you become the host) or join one with a 6-character code.
separate login — it's a real page inside Emby, using your existing session. 4. The host's play/pause/seek drives everyone else's player automatically,
3. Friends open the same **Watch Party** menu item, start playing the *same* over a small backend that only ever coordinates timing — actual video
title in their own Emby app, and enter the code to join. always streams directly from your Emby server to each viewer's browser.
4. The server treats one member (the host, by default) as the reference
position, and periodically nudges everyone else's session back in sync
(seek correction, and mirroring pause/unpause) — see
[docs/architecture.md](docs/architecture.md#sync-algorithm).
The Watch Party page is a companion panel, not a video player — it never
touches the actual video stream, playback stays in your normal Emby app.
**Known limitation**: the menu item shows up on Web and Electron clients.
Official mobile/TV apps don't surface arbitrary plugin pages, so Watch Party
won't appear there — see [docs/architecture.md](docs/architecture.md#components).
## Status ## Status
Builds clean (0 errors, 0 warnings) against the real `MediaBrowser.Server.Core` Freshly scaffolded and smoke-tested (server starts, routes render, error
/ `MediaBrowser.Common` 4.9.1.90 assemblies from NuGet, and is installed on a handling works — see git history) with a stubbed Emby server. **Not yet run
real Emby Server (Docker) — the plugin loads and its admin page shows up. against a real Emby server.** The biggest open question is CORS: the browser
The actual feature (`partyPage.html`, reached via `EnableInUserMenu`) hasn't calls Emby directly from a different origin, which needs Emby (or its
been exercised live yet. See reverse proxy) to answer that cross-origin requests are allowed — see
[docs/architecture.md](docs/architecture.md#assumptions-to-verify) for [docs/architecture.md](docs/architecture.md#assumptions-to-verify).
exactly what was wrong on the first guess, what was fixed, and what's still
open.
## Requirements
- Emby Server (developed against 4.9.5.0; should work on nearby 4.9.x/4.8.x
builds, but the plugin ABI does shift between versions)
- [.NET SDK](https://dotnet.microsoft.com/download) 8.0 to build (installed
per-user under `%USERPROFILE%\.dotnet` in this environment, since the
machine-wide winget install needed admin elevation that wasn't available —
see git history if you need to redo this on another machine)
## Project layout ## Project layout
``` ```
src/WatchParty/ the plugin itself (C#, netstandard2.0) server/ Node.js backend: party/room coordination + WebSocket sync.
Plugin.cs plugin registration + both plugin pages Never touches media bytes - only validates Emby tokens
Configuration/ plugin settings, the admin settings page, and (by asking Emby itself) and relays playback state.
partyPage.html - the actual user-facing feature, src/index.js Express app + REST endpoints
integrated into Emby's own menu (not a separate app) src/wsHandler.js WebSocket protocol (see comments in the file)
Parties/ Party / PartyMember model + in-memory PartyManager src/partyManager.js in-memory room state
Sync/ the drift-correction background loop src/embyAuth.js token validation against your Emby server
Api/ REST endpoints, called from partyPage.html
docs/architecture.md full design doc client/ Static frontend (plain JS, ES modules, no build step).
Talks directly to Emby for catalog/images/streaming, and
to server/ only for party coordination.
js/embyApi.js Emby REST calls (auth, search, images, stream URLs)
js/party.js our own backend's REST + WebSocket client
js/views/ one file per screen (login, browse, item, player)
src/WatchParty/ The earlier Emby-plugin attempt. Builds fine and installs,
but Emby has no supported extension point for a player
button, and even menu integration required reverse-
engineering undocumented internals - see
docs/architecture.md for the full story. Kept for
reference; not the active direction.
docs/architecture.md full design doc for the standalone app
``` ```
## Building ## Running locally
```bash ```bash
dotnet build src/WatchParty/WatchParty.csproj cd server
npm install
EMBY_SERVER_URL=https://your-emby-server npm start
``` ```
To have a build drop the DLL straight into a local Emby Server's plugin Then open `http://localhost:3000`.
folder, set `EMBY_PLUGINS_DIR` before building, e.g.:
## Deploying
Build the Docker image from the **repo root** (it needs both `server/` and
`client/`):
```bash ```bash
EMBY_PLUGINS_DIR="C:/ProgramData/Emby-Server/programdata/plugins" dotnet build src/WatchParty/WatchParty.csproj docker build -f server/Dockerfile -t watchparty .
docker run -d --name watchparty -p 3000:3000 -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty
``` ```
Adjust the path for however your server is installed (Windows service vs. Then put it behind your existing reverse proxy like everything else
portable install vs. Docker volume). (`theater.media.kyuno.fr`, `git.dev.kyuno.fr`, ...), e.g.
`watchparty.kyuno.fr` → `http://<container>:3000`.
## Roadmap ## Roadmap
- [x] Design doc + scaffold - [x] Design + scaffold the standalone app
- [x] Build against the real SDK, fix whatever the `TODO(verify)` spots got wrong - [x] Smoke-test locally (routing, error handling) without a real Emby server
- [x] Install on a real Emby Server (Docker) - [ ] Verify against the real Emby server (CORS is the main risk - see above)
- [x] Integrate into Emby's own UI instead of a standalone web app
(`EnableInUserMenu` — see [docs/architecture.md](docs/architecture.md#components))
- [ ] Try a full party end-to-end with two accounts - [ ] Try a full party end-to-end with two accounts
- [ ] Live (WebSocket) updates in the party page instead of polling - [ ] TV show navigation (seasons/episodes) beyond flat search
- [ ] Per-party override of sync settings instead of only server-wide defaults - [ ] Tune transcoding target / support direct play when possible
No chat/reactions — deliberately out of scope, not just deferred. No chat, no OIDC — deliberately out of scope (Emby-only auth was the
explicit choice; see docs/architecture.md).
## License ## License

176
client/css/app.css Normal file
View file

@ -0,0 +1,176 @@
:root {
color-scheme: light dark;
--bg: #111418;
--panel: #1b1f26;
--text: #e8eaed;
--muted: #9aa4b2;
--accent: #5b8cff;
--danger: #ff6b6b;
--ok: #4cd97b;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
#app { min-height: 100%; }
.wrap {
max-width: 480px;
margin: 0 auto;
padding: 32px 20px 80px;
}
.wrap.wide { max-width: 900px; }
h1 { font-size: 22px; margin: 0; }
h2 { font-size: 16px; margin: 0 0 8px; }
.topbar {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 20px;
}
.panel {
background: var(--panel);
border-radius: 12px;
padding: 20px;
}
.hint { color: var(--muted); font-size: 13px; margin: 0 0 16px; }
.error { color: var(--danger); font-size: 13px; min-height: 18px; }
label {
display: block;
font-size: 13px;
color: var(--muted);
margin-bottom: 12px;
}
input {
display: block;
width: 100%;
margin-top: 4px;
padding: 10px;
border-radius: 8px;
border: 1px solid #333a45;
background: #0e1116;
color: var(--text);
font-size: 14px;
}
input[type="search"] { margin-bottom: 20px; }
button {
padding: 12px 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
button.linklike {
background: none;
color: var(--muted);
font-size: 12px;
font-weight: 400;
text-decoration: underline;
padding: 4px 0;
}
.or { text-align: center; color: var(--muted); font-size: 12px; margin: 16px 0; }
.code { color: var(--accent); letter-spacing: .1em; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
.card {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--text);
text-decoration: none;
font-size: 13px;
}
.card img {
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
background: var(--panel);
}
.detail {
display: flex;
gap: 20px;
margin-top: 16px;
}
.detail img {
width: 160px;
border-radius: 8px;
flex-shrink: 0;
}
.detail button { width: 100%; margin-top: 8px; }
.playerWrap {
position: relative;
width: 100%;
height: 100vh;
background: #000;
}
.playerWrap video {
width: 100%;
height: 100%;
object-fit: contain;
}
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
background: linear-gradient(to bottom, rgba(0,0,0,.7), transparent);
color: white;
font-size: 13px;
}
.overlay .members { color: var(--muted); flex: 1; }
.followerNotice {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,.6);
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
color: var(--muted);
}
.hide { display: none; }

14
client/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Watch Party</title>
<link rel="stylesheet" href="css/app.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="js/app.js"></script>
</body>
</html>

24
client/js/app.js Normal file
View file

@ -0,0 +1,24 @@
import { route, start, navigate } from './router.js';
import { session } from './embyApi.js';
import { renderLogin } from './views/login.js';
import { renderBrowse } from './views/browse.js';
import { renderItemDetail } from './views/itemDetail.js';
import { renderPartyPlayer } from './views/player.js';
function requireAuth(renderFn) {
return (root, params) => {
if (!session.isLoggedIn) {
navigate('/login');
return;
}
renderFn(root, params);
};
}
route('/', () => navigate(session.isLoggedIn ? '/browse' : '/login'));
route('/login', renderLogin);
route('/browse', requireAuth(renderBrowse));
route('/item/:id', requireAuth(renderItemDetail));
route('/party/:id', requireAuth(renderPartyPlayer));
start(document.getElementById('app'));

110
client/js/embyApi.js Normal file
View file

@ -0,0 +1,110 @@
// Direct browser <-> Emby calls: catalog, images, streaming. Session/token
// lives in localStorage, scoped to this browser only. Nothing here ever
// talks to our own backend - see party.js for that.
//
// TODO(verify): these calls go cross-origin (this app's domain -> Emby's
// domain), which needs Emby (or its reverse proxy) to answer CORS preflight
// requests. If login/search silently fail with a network error and the
// browser console shows a CORS complaint, this whole approach needs a proxy
// through our own backend instead - see docs/architecture.md.
function authHeaderValue() {
let deviceId = localStorage.getItem('wp_deviceId');
if (!deviceId) {
deviceId = 'watchparty-' + crypto.randomUUID();
localStorage.setItem('wp_deviceId', deviceId);
}
return `MediaBrowser Client="WatchParty", Device="Browser", DeviceId="${deviceId}", Version="0.1.0"`;
}
export const session = {
get serverUrl() { return localStorage.getItem('wp_serverUrl') || ''; },
get token() { return localStorage.getItem('wp_token') || ''; },
get userId() { return localStorage.getItem('wp_userId') || ''; },
get userName() { return localStorage.getItem('wp_userName') || ''; },
get isLoggedIn() { return !!(this.serverUrl && this.token && this.userId); },
set(serverUrl, token, userId, userName) {
localStorage.setItem('wp_serverUrl', serverUrl);
localStorage.setItem('wp_token', token);
localStorage.setItem('wp_userId', userId);
localStorage.setItem('wp_userName', userName);
},
clear() {
['wp_serverUrl', 'wp_token', 'wp_userId', 'wp_userName'].forEach((k) => localStorage.removeItem(k));
}
};
async function embyFetch(path, options = {}) {
const res = await fetch(session.serverUrl.replace(/\/+$/, '') + path, {
...options,
headers: {
'X-Emby-Authorization': authHeaderValue(),
...(session.token ? { 'X-Emby-Token': session.token } : {}),
...(options.headers || {})
}
});
if (!res.ok) {
throw new Error(`Emby request failed: ${res.status}`);
}
const text = await res.text();
return text ? JSON.parse(text) : null;
}
export async function login(serverUrl, username, password) {
const cleanUrl = serverUrl.trim().replace(/\/+$/, '');
const res = await fetch(cleanUrl + '/Users/AuthenticateByName', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': authHeaderValue()
},
body: JSON.stringify({ Username: username, Pw: password })
});
if (!res.ok) {
throw new Error('Identifiants refusés.');
}
const data = await res.json();
session.set(cleanUrl, data.AccessToken, data.User.Id, data.User.Name);
return data;
}
export async function searchItems(query) {
if (!query || query.trim().length < 2) {
return [];
}
const params = new URLSearchParams({
searchTerm: query,
IncludeItemTypes: 'Movie,Episode,Series',
Recursive: 'true',
Fields: 'Overview,ProductionYear',
ImageTypeLimit: '1',
Limit: '24'
});
const data = await embyFetch(`/Users/${session.userId}/Items?${params.toString()}`);
return (data && data.Items) || [];
}
export async function getItem(itemId) {
return embyFetch(`/Users/${session.userId}/Items/${itemId}`);
}
export function imageUrl(itemId, maxWidth = 300) {
// <img> can't send custom headers, so the token has to ride along in the
// query string here - the one deliberate exception to keeping tokens out
// of URLs. This is the same thing Emby's own web client does for images.
const params = new URLSearchParams({ api_key: session.token, maxWidth: String(maxWidth) });
return `${session.serverUrl}/Items/${itemId}/Images/Primary?${params.toString()}`;
}
export function streamUrl(itemId) {
// Same deliberate exception as imageUrl: <video src>/hls.js can't attach
// headers either. VideoCodec/AudioCodec pinned to a broadly-compatible
// target so Emby transcodes whatever it needs to - good enough for v1,
// not tuned for quality/bandwidth yet.
const params = new URLSearchParams({
api_key: session.token,
VideoCodec: 'h264',
AudioCodec: 'aac'
});
return `${session.serverUrl}/Videos/${itemId}/master.m3u8?${params.toString()}`;
}

75
client/js/party.js Normal file
View file

@ -0,0 +1,75 @@
// Everything that talks to OUR OWN backend: party REST endpoints, and the
// realtime WebSocket. Same origin as this page, so no CORS concerns here -
// contrast with embyApi.js.
import { session } from './embyApi.js';
async function apiFetch(path, options = {}) {
const res = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Emby-Token': session.token,
'X-Emby-UserId': session.userId,
...(options.headers || {})
}
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
export function createParty(itemId) {
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId }) });
}
export function joinParty(joinCode) {
return apiFetch(`/api/parties/${encodeURIComponent(joinCode)}/join`, { method: 'POST' });
}
export function leaveParty(partyId) {
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
}
export function getParty(partyId) {
return apiFetch(`/api/parties/${partyId}`);
}
/**
* Opens the realtime connection for a party. The Emby token is sent as the
* FIRST WebSocket message, not a query string - tokens don't belong in URLs
* (they'd end up in server logs, browser history, etc.).
*/
export function connectParty(partyId, { onMembers, onPlaystate, onError, onClose } = {}) {
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(proto + location.host + '/ws');
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'auth', token: session.token, userId: session.userId, partyId }));
});
ws.addEventListener('message', (evt) => {
let msg;
try { msg = JSON.parse(evt.data); } catch (err) { return; }
if (msg.type === 'members' && onMembers) onMembers(msg.members);
if (msg.type === 'playstate' && onPlaystate) onPlaystate(msg);
if (msg.type === 'error' && onError) onError(msg.message);
});
ws.addEventListener('close', () => { if (onClose) onClose(); });
return {
sendPlaystate(isPaused, positionTicks) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'playstate', isPaused, positionTicks }));
}
},
leave() {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'leave' }));
}
ws.close();
}
};
}

44
client/js/router.js Normal file
View file

@ -0,0 +1,44 @@
// Tiny hash router - no dependency, matches the project's "no build step"
// bias. Routes are registered with route(pattern, handler); handler gets
// (root, params) where params come from ":name" segments in the pattern.
const routes = [];
export function route(pattern, handler) {
const keys = [];
const regexSource = pattern
.split('/')
.map((segment) => {
if (segment.startsWith(':')) {
keys.push(segment.slice(1));
return '([^/]+)';
}
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
})
.join('/');
routes.push({ regex: new RegExp('^' + regexSource + '$'), keys, handler });
}
export function navigate(path) {
location.hash = '#' + path;
}
export function start(root) {
function render() {
const path = (location.hash.slice(1) || '/').split('?')[0];
for (const r of routes) {
const match = path.match(r.regex);
if (match) {
const params = {};
r.keys.forEach((key, i) => { params[key] = decodeURIComponent(match[i + 1]); });
root.innerHTML = '';
r.handler(root, params);
return;
}
}
root.innerHTML = '<main class="wrap"><p>Page introuvable.</p></main>';
}
window.addEventListener('hashchange', render);
render();
}

59
client/js/views/browse.js Normal file
View file

@ -0,0 +1,59 @@
import { searchItems, imageUrl, session } from '../embyApi.js';
import { navigate } from '../router.js';
export function renderBrowse(root) {
root.innerHTML = `
<main class="wrap wide">
<div class="topbar">
<h1>Watch Party</h1>
<button class="linklike" id="logout">Se déconnecter (${session.userName})</button>
</div>
<input id="search" type="search" placeholder="Cherche un film, une série, un épisode..." autofocus />
<div id="results" class="grid"></div>
</main>`;
root.querySelector('#logout').addEventListener('click', () => {
session.clear();
navigate('/login');
});
const input = root.querySelector('#search');
const results = root.querySelector('#results');
let debounceTimer;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(runSearch, 300);
});
async function runSearch() {
const query = input.value;
if (!query || query.trim().length < 2) {
results.innerHTML = '';
return;
}
let items;
try {
items = await searchItems(query);
} catch (err) {
results.innerHTML = '<p class="error">Recherche impossible - le serveur Emby est-il joignable ?</p>';
return;
}
if (!items.length) {
results.innerHTML = '<p class="hint">Aucun résultat.</p>';
return;
}
results.innerHTML = items.map((item) => `
<a class="card" href="#/item/${item.Id}">
<img src="${imageUrl(item.Id)}" alt="" loading="lazy" />
<span>${escapeHtml(item.Name)}${item.ProductionYear ? ' (' + item.ProductionYear + ')' : ''}</span>
</a>
`).join('');
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}

View file

@ -0,0 +1,58 @@
import { getItem, imageUrl } from '../embyApi.js';
import { createParty, joinParty } from '../party.js';
import { navigate } from '../router.js';
export function renderItemDetail(root, params) {
root.innerHTML = '<main class="wrap"><p class="hint">Chargement...</p></main>';
getItem(params.id).then((item) => {
root.innerHTML = `
<main class="wrap">
<button class="linklike" id="back">&larr; Retour</button>
<div class="detail">
<img src="${imageUrl(item.Id, 400)}" alt="" />
<div>
<h1>${escapeHtml(item.Name)}</h1>
<p>${escapeHtml(item.Overview || '')}</p>
<button id="createBtn">Démarrer une watch party</button>
<div class="or">ou</div>
<label>Code de la party
<input id="joinCode" maxlength="6" style="text-transform:uppercase" />
</label>
<button id="joinBtn">Rejoindre</button>
<p id="error" class="error"></p>
</div>
</div>
</main>`;
root.querySelector('#back').addEventListener('click', () => history.back());
root.querySelector('#createBtn').addEventListener('click', async () => {
try {
const party = await createParty(item.Id);
navigate(`/party/${party.partyId}`);
} catch (err) {
root.querySelector('#error').textContent = err.message;
}
});
root.querySelector('#joinBtn').addEventListener('click', async () => {
const code = root.querySelector('#joinCode').value.trim();
try {
const party = await joinParty(code);
navigate(`/party/${party.partyId}`);
} catch (err) {
root.querySelector('#error').textContent = err.message;
}
});
}).catch(() => {
root.innerHTML = '<main class="wrap"><p class="error">Titre introuvable.</p></main>';
});
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}

46
client/js/views/login.js Normal file
View file

@ -0,0 +1,46 @@
import { login } from '../embyApi.js';
import { navigate } from '../router.js';
export function renderLogin(root) {
root.innerHTML = `
<main class="wrap">
<h1>Watch Party</h1>
<form id="loginForm" class="panel">
<p class="hint">Utilise ton compte Emby habituel.</p>
<label>Adresse du serveur Emby
<input id="serverUrl" placeholder="https://theater.media.kyuno.fr" required />
</label>
<label>Nom d'utilisateur
<input id="username" autocomplete="username" required />
</label>
<label>Mot de passe
<input id="password" type="password" autocomplete="current-password" required />
</label>
<button type="submit">Se connecter</button>
<p id="error" class="error"></p>
</form>
</main>`;
const form = root.querySelector('#loginForm');
const serverUrlInput = form.querySelector('#serverUrl');
serverUrlInput.value = localStorage.getItem('wp_lastServerUrl') || '';
form.addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = form.querySelector('#error');
errorEl.textContent = '';
const serverUrl = serverUrlInput.value.trim();
const username = form.querySelector('#username').value;
const password = form.querySelector('#password').value;
localStorage.setItem('wp_lastServerUrl', serverUrl);
try {
await login(serverUrl, username, password);
navigate('/browse');
} catch (err) {
errorEl.textContent =
"Connexion impossible - vérifie l'adresse du serveur, le nom d'utilisateur et le mot de passe. " +
"(Si ça persiste, ouvre la console du navigateur : une erreur CORS s'affiche différemment d'un simple refus d'identifiants.)";
}
});
}

102
client/js/views/player.js Normal file
View file

@ -0,0 +1,102 @@
import { getItem, streamUrl, session } from '../embyApi.js';
import { getParty, leaveParty, connectParty } from '../party.js';
import { navigate } from '../router.js';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
const DRIFT_TOLERANCE_SECONDS = 1.5;
export function renderPartyPlayer(root, params) {
const partyId = params.id;
root.innerHTML = `
<div class="playerWrap">
<video id="video" playsinline></video>
<div class="overlay">
<span>Code: <span id="code" class="code"></span></span>
<span id="members" class="members"></span>
<button id="leaveBtn" class="linklike">Quitter</button>
</div>
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
</div>`;
const video = root.querySelector('#video');
const codeEl = root.querySelector('#code');
const membersEl = root.querySelector('#members');
const followerNotice = root.querySelector('#followerNotice');
let isHost = false;
let ws = null;
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event
let heartbeat = null;
function applyRemoteState(msg) {
suppressLocalEvents = true;
const targetSeconds = msg.positionTicks / TICKS_PER_SECOND;
if (Math.abs(video.currentTime - targetSeconds) > DRIFT_TOLERANCE_SECONDS) {
video.currentTime = targetSeconds;
}
if (msg.isPaused && !video.paused) video.pause();
if (!msg.isPaused && video.paused) video.play().catch(() => {});
setTimeout(() => { suppressLocalEvents = false; }, 250);
}
function sendLocalState() {
if (!isHost || suppressLocalEvents || !ws) return;
ws.sendPlaystate(video.paused, Math.round(video.currentTime * TICKS_PER_SECOND));
}
async function init() {
let party;
try {
party = await getParty(partyId);
} catch (err) {
alert(err.message);
navigate('/browse');
return;
}
isHost = party.hostUserId === session.userId;
codeEl.textContent = party.joinCode;
video.controls = isHost;
followerNotice.classList.toggle('hide', isHost);
const item = await getItem(party.itemId);
const src = streamUrl(item.Id);
if (window.Hls && window.Hls.isSupported()) {
const hls = new window.Hls();
hls.loadSource(src);
hls.attachMedia(video);
} else {
video.src = src;
}
ws = connectParty(partyId, {
onMembers: renderMembers,
onPlaystate: applyRemoteState,
onError: (message) => alert(message),
onClose: () => {}
});
if (isHost) {
video.addEventListener('play', sendLocalState);
video.addEventListener('pause', sendLocalState);
video.addEventListener('seeked', sendLocalState);
heartbeat = setInterval(sendLocalState, 3000);
}
}
function renderMembers(members) {
membersEl.textContent = members
.map((m) => m.userName + (m.isHost ? ' (hôte)' : ''))
.join(', ');
}
root.querySelector('#leaveBtn').addEventListener('click', async () => {
if (heartbeat) clearInterval(heartbeat);
if (ws) ws.leave();
await leaveParty(partyId).catch(() => {});
navigate('/browse');
});
init();
}

10
docker-compose.yml Normal file
View file

@ -0,0 +1,10 @@
services:
watchparty:
build:
context: .
dockerfile: server/Dockerfile
restart: unless-stopped
ports:
- "3000:3000"
environment:
EMBY_SERVER_URL: "https://theater.media.kyuno.fr"

View file

@ -2,292 +2,155 @@
## Goals ## Goals
- Watch the same title, at (roughly) the same time, as other users on the - Watch the same title, in sync, with other users of the same Emby server.
same Emby server. - Use Emby as the media source (catalog + streaming) without requiring
- Don't require anyone to change how they watch — no new client, no browser changes to Emby itself or its official clients.
extension, no modified apps. People keep using whatever Emby app they - Access gated to people with an Emby account on this server — no anonymous
already use. use, no separate identity system.
- Ship as a single Emby Server plugin.
## Non-goals (for v1) ## Non-goals
- Frame-accurate sync. A few seconds of drift between corrections is - Chat/reactions — deliberately out of scope.
acceptable — that's inherent to the "generic remote control" approach (see - OIDC/SSO — an OIDC provider (LemonLDAP::NG) is available in principle, but
below), and is a conscious tradeoff, not an oversight. the explicit choice was Emby-only auth: simpler, no extra infra dependency,
- A shared/forced "host controls everyone" model. The chosen model (see and everyone already has an Emby account.
"Control model" below) is: everyone keeps their own play/pause/seek - Full library navigation (Shows → Seasons → Episodes) in v1 — search-based
controls; the party only shares a reference position to resync against. catalog only, to keep the first version achievable. Revisit if browsing
- Cross-server parties, chat, reactions. Possible later, not in v1 — see turns out to matter more than searching in practice.
Roadmap in the README.
## The key idea: reuse Emby's existing remote-control commands ## Why this replaced an Emby plugin
Emby already has an authoritative-session model for "cast" / "remote control The first attempt (still in [`src/WatchParty/`](../src/WatchParty/), kept for
another device" style features: `ISessionManager` tracks every active reference) was a real Emby Server plugin: sync via `ISessionManager`'s
session's playback state, and a session can be commanded via existing remote-control commands, a settings page via `IHasWebPages`, and —
`SendPlaystateCommand` (`Play`, `Pause`, `Unpause`, `Seek`, ...) or after a fair amount of reverse-engineering — a real user-facing page
`SendGeneralCommand`. This already exists in Emby Server and, to whatever integrated into Emby's own web client menu (`PluginPageInfo.EnableInUserMenu`,
extent each client supports being remote-controlled at all (most official undocumented but confirmed working; required a `data-controller` AMD module
clients do, since it's how casting/remote-play works), in the clients too. to satisfy Emby's modern `ViewManager`, found by reading the target server's
actual `viewmanager.js`).
So instead of building a new synchronized player, this plugin is mostly a That plugin builds and installs correctly. What it hit a hard wall on: **a
coordinator sitting on top of that existing mechanism: button inside the video player itself**, or on an item's detail page. An
Emby developer confirmed on their own forum that plugins have no API for
this ("It's currently not possible for a plugin to do this"), and nothing
found in research since suggests that's changed. The only way to get a
button into Emby's own player would be patching Emby's shipped web client
files directly inside the Docker container — outside the plugin entirely,
guaranteed to break on every Emby image update, and requiring manual
reapplication each time. That trade-off wasn't worth it.
1. Track which sessions belong to which "party". Building a standalone app sidesteps the question entirely: it's our own
2. Watch each member's reported position/pause state (the same data Emby's player, so there's no "how do I add a button to someone else's UI" problem
own "Now Playing" / dashboard already reads). in the first place, and sync can be tighter than nudging Emby's
3. When a member drifts too far from the party's reference position, or its remote-control commands ever allowed (see below).
pause state disagrees with the reference, send the same kind of
Seek/Pause/Unpause command Emby would send for ordinary remote control.
Sources consulted while designing this: Full trace of the plugin investigation (real errors, real fixes, sources
- [How to build a Server Plugin](https://github.com/MediaBrowser/Emby/wiki/How-to-build-a-Server-Plugin) — project setup, `BasePlugin<T>`, `IServerEntryPoint`. consulted) is in the git history of this file and of `src/WatchParty/` —
- [Creating Api Endpoints](https://github.com/MediaBrowser/Emby/wiki/Creating-Api-Endpoints) — the `IService`/`[Route]`/`IReturn<T>` REST pattern. worth reading if a future Emby SDK update ever makes in-player integration
- [Emby plugin dev docs](https://dev.emby.media/doc/plugins/dev/index.html) — confirms `netstandard2.0`, .NET Core/Mono runtimes. possible and this gets revisited.
- [`MediaBrowser.Server.Core` on NuGet](https://www.nuget.org/packages/mediabrowser.server.core) — package to reference; latest published at research time was `4.9.1.90`.
- Emby community thread on [remote-controlling seek](https://emby.media/community/topic/119432-emby-remote-control-another-client-to-seek-tracks/), and Jellyfin's (Emby-derived) [`SessionManager.cs`](https://github.com/jellyfin/jellyfin/blob/master/Emby.Server.Implementations/Session/SessionManager.cs) — shape of `SendPlaystateCommand(controllingSessionId, sessionId, PlaystateRequest, CancellationToken)` and `PlaystateRequest { Command, SeekPositionTicks, ControllingUserId }`.
## Control model
Design decision: **each participant keeps their own player and controls; the
party only shares a reference position to resync against.** Concretely:
- Nobody's play/pause/seek is disabled. You control your own playback
exactly like you always do.
- One member is the *reference* (the host, by default — falls back to
whoever's left if the host leaves). The server treats the reference
session's reported position/pause state as ground truth for the party.
- Other members get corrected against the reference:
- **Pause/unpause is mirrored immediately** (configurable —
`SyncPausePlay`, on by default): if the reference pauses, everyone gets a
`Pause` command; if it unpauses, everyone gets `Unpause`.
- **Position drift beyond a threshold is corrected with a `Seek`**, on a
cooldown so a shaky connection doesn't cause a stream of seek commands
(`DriftThresholdSeconds`, `ResyncCooldownSeconds`).
- Anyone can also hit **"Resync me now"** in the control panel for an
immediate correction, bypassing the cooldown.
This deliberately does *not* try to stop someone from pausing or seeking
locally — it just keeps pulling things back toward the reference. That fits
"loose sync via existing remote-control commands" much better than trying to
fight every local action.
## Sync algorithm
Runs on a timer (`SyncTickSeconds`, default 5s) inside `PartySyncEntryPoint`,
per active party:
```
reference = party.Members[party.ReferenceUserId]
referencePos = reference.EstimatedPositionTicks(now) # extrapolated if playing
for member in party.Members, excluding reference:
if SyncPausePlay and member.IsPaused != reference.IsPaused:
send Pause/Unpause to member's session
continue
if now - member.LastResyncUtc < ResyncCooldownSeconds:
continue # give the last correction time to take effect
drift = abs(member.EstimatedPositionTicks(now) - referencePos)
if drift > DriftThresholdSeconds:
send Seek(referencePos) to member's session
```
`EstimatedPositionTicks` extrapolates from the last known position using
elapsed wall-clock time when playing, so members aren't marked "drifted" just
because their last progress event was a few seconds ago.
Position/pause state itself comes from Emby's own session events
(`ISessionManager.PlaybackStart` / `PlaybackProgress` / `PlaybackStopped`) —
the plugin doesn't poll clients itself.
## Which session is "mine"?
The trickiest part of gluing "a user" to "a specific playback session",
since one user can have several active sessions (phone + TV + browser tab).
v1's blunt heuristic (`WatchPartyService.FindCallerSession`): when you
create/join a party, the server looks at your currently active sessions,
keeps the ones actually playing something (and, when joining, playing the
*same item* as the party), and picks the most recently active one. Good
enough for "one person, one device, actively watching" — the expected case —
but it's a real limitation if you're doing something else on a second device
at the same time. Documented here rather than hidden.
## Components ## Components
``` ```
src/WatchParty/ server/ Node.js + Express + ws. Owns party/room state and the realtime
Plugin.cs BasePlugin<PluginConfiguration> + both plugin pages (see GetPages()) sync channel. Never touches a media byte - see "Media never
Configuration/ flows through our backend" below.
PluginConfiguration.cs defaults (drift threshold, cooldown, etc.)
configPage.html admin settings page (Dashboard > Plugins > Watch Party) client/ Static frontend, plain JS + ES modules, no build step (same bias
partyPage.html the actual feature: create/join, member list, resync/leave - as the earlier plugin's control panel). Talks to two different
EnableInUserMenu=true, so regular users see it too things and must not confuse them:
Parties/ - Emby directly (embyApi.js): catalog, images, streaming.
Party.cs party id, join code, item id, host/reference user, members - Our own backend (party.js): create/join/leave a party, and
PartyMember.cs per-member session id + last known position/pause + EstimatedPositionTicks the WebSocket for realtime sync.
PartyManager.cs in-memory store (ConcurrentDictionary); create/join/leave/lookup
Sync/
PartySyncEntryPoint.cs IServerEntryPoint: session event wiring + the tick loop above
Api/
WatchPartyService.cs REST endpoints (create/join/leave/get/resync), called from
partyPage.html via the Emby web client's own ApiClient
``` ```
Parties are **in-memory only** — a server restart clears them. That's fine ### Auth
for v1: a party is meant to live for one watch session, not persist for
weeks.
The party UI is a real Emby plugin page (`Configuration/partyPage.html`, Emby-only, as decided. The browser calls Emby's own
registered in `Plugin.cs` via `IHasWebPages.GetPages()`), not a separate `POST /Users/AuthenticateByName` directly (same call the old plugin's
web app. `PluginPageInfo` turns out to support exactly this: setting control panel used) and keeps the resulting `AccessToken`/`User.Id` in
`EnableInUserMenu = true` (`Plugin.cs`) surfaces the page to regular users, not `localStorage`. Our backend never sees a password — only ever a token the
just admins — the same mechanism as the admin settings page browser already obtained from Emby, sent as a header (`X-Emby-Token` /
(`configPage.html`), just with different visibility flags. This was not `X-Emby-UserId`) on REST calls, or as the first WebSocket message (not a
obvious from the official docs (which only cover the admin/dashboard case) — query string — tokens don't belong in URLs). The backend validates a token
confirmed instead via reflection on the real `PluginPageInfo` type (it has by asking Emby itself (`GET /Users/{userId}` with that token) rather than
`EnableInMainMenu`, `EnableInUserMenu`, `MenuSection`, `MenuIcon`, trying to verify anything cryptographically — see `server/src/embyAuth.js`.
`IsMainConfigPage`) and an Emby community thread showing another plugin using
`EnableInUserMenu = true` in practice.
Two consequences of doing it this way instead of a standalone page: ### Media never flows through our backend
- No separate login: the page runs inside the already-authenticated Emby web
client and just calls the global `ApiClient`, the same as the admin
settings page already did. There's no server URL / username / password
form, no token stored anywhere by this plugin.
- **Known limitation**: this reaches the **Web browser and Electron desktop
client only**. Official mobile/TV apps allowlist which plugin pages they
render client-side (independent of what the server exposes), so the page
won't show up there without changes on the client side that aren't
realistic to make from this plugin. Playback sync itself (the
`SendPlaystateCommand` nudges) still works on any client regardless — it's
only the *page to create/join a party* that's Web/Electron-only.
An earlier version of this plugin shipped a fully standalone web app `client/js/embyApi.js` builds Emby URLs (images, HLS stream) directly, and
(`web/control-panel/`, its own login form, served via custom the browser fetches them straight from Emby. `server/` only ever sees JSON
`GET /WatchParty/app` routes) to sidestep exactly this uncertainty. It was party-coordination traffic and a handful of bytes per WebSocket message. The
removed once `EnableInUserMenu` was confirmed to work — see git history if one deliberate exception: `<img>`/`<video>` elements can't send custom
you ever need the standalone version back (e.g. to reach a client where the headers, so their URLs carry the Emby token as an `api_key` query parameter
integrated page doesn't show up). — unavoidable, and the same thing Emby's own web client does for images.
### Control model: host drives, everyone else follows
Now that we own the player, the classic watch-party model (Teleparty,
Amazon Watch Party, ...) fits better than the old plugin's "everyone keeps
their own controls, shared reference position" — that design was specifically
a concession to nudging *pre-existing, independently-running* Emby app
sessions we didn't control. Here, there's only one player implementation,
so:
- The party creator is the host. Their player has normal controls.
- Everyone else's `<video>` has controls hidden; a small notice says the
host is in charge.
- Host → server → everyone else, over the WebSocket
(`server/src/wsHandler.js`):
- Event-driven: every `play`/`pause`/`seeked` on the host's video sends
`{type:"playstate", isPaused, positionTicks}` immediately.
- Heartbeat: also sent every 3s regardless, so latecomers/drift get
corrected even without a discrete host action.
- Followers apply pause/unpause immediately, and only seek if drift
exceeds 1.5s (`DRIFT_TOLERANCE_SECONDS` in `client/js/views/player.js`)
— avoids fighting small timing noise with constant re-seeks.
- If the host leaves, the server promotes another member (same
fallback logic the old plugin used for its "reference" member).
### Party lifecycle
In-memory only on the backend (`server/src/partyManager.js`) — a restart
clears every party, which is fine: a party is meant to last one watch
session, not persist for weeks. `removeIdleParties` sweeps anything with no
activity for 4 hours.
## Assumptions to verify ## Assumptions to verify
Update: the .NET SDK is now installed and `src/WatchParty` **builds clean Unlike the plugin (which got to compile against Emby's real SDK assemblies),
(0 errors, 0 warnings) against the real `MediaBrowser.Server.Core` / none of the Emby REST API assumptions below have been exercised against a
`MediaBrowser.Common` 4.9.1.90 assemblies**, restored from NuGet. Most of the real server yet — only smoke-tested against a stubbed/fake Emby URL to
list below turned out right on the first guess; a few didn't, and were fixed confirm the *app's own* code paths (routing, error handling) don't crash
by reading the actual assemblies (via .NET reflection) and then letting the (see git history). Check these first when testing for real:
compiler confirm. Keeping the history here since it's a useful map of where
this SDK surprises you.
**Confirmed correct as originally written** (compiled with no changes needed): - **CORS** — the browser calls Emby from a different origin than this app.
- `BasePlugin<T>` constructor shape, `IServerEntryPoint`, `netstandard2.0` + If Emby (or the openresty reverse proxy in front of it) doesn't answer
`MediaBrowser.Server.Core` package — all from the official wiki. preflight requests permissively, every direct call (including login)
- `IHasWebPages` (not `IHasUIPages` — that name does exist too, in fails with a browser-level CORS error, not a normal HTTP error. Open the
`MediaBrowser.Model.Plugins.UI`, but `IHasWebPages` in browser console on first test; if you see "CORS" anywhere, the fix is
`MediaBrowser.Model.Plugins` is the one that's actually wired up here) and routing `embyApi.js`'s JSON calls through `server/` as a proxy instead of
`PluginPageInfo.Name`/`.EmbeddedResourcePath`. direct — video/image `src` URLs wouldn't need this (no CORS for those).
- `ISessionManager.SendPlaystateCommand(string controllingSessionId, string - **`/Users/{userId}` for token validation** — chosen deliberately over a
sessionId, PlaystateRequest, CancellationToken)`, `SendGeneralCommand`, and guessed `/Users/Me` shortcut (not confirmed to exist on this Emby
`PlaystateRequest { Command, SeekPositionTicks }` / `PlaystateCommand.{Pause, version), but not confirmed to behave as expected either.
Unpause, Seek}`. - **`/Videos/{id}/master.m3u8`** for HLS playback, with
- `ILogManager.GetLogger(name)` / `ILogger.Debug(...)` / `VideoCodec=h264&AudioCodec=aac` forcing a broadly-compatible transcode
`.ErrorException(...)` — still the live pattern on 4.9.x, not replaced by target. Works for basically anything Emby can transcode, but is never the
`Microsoft.Extensions.Logging`. most efficient path (no direct play, no bitrate/quality negotiation) —
- `IRequiresRequest` / `IService` / `[Route]` / `IReturn<T>` for the REST fine for v1, worth revisiting once the basic flow works.
endpoints. - **Response field names** (`AccessToken`, `User.Id`, `User.Name`,
- `SessionInfo.PlayState.PositionTicks` (`long?`) and `.IsPaused` (`bool`). `Items[].Id/Name/Overview/ProductionYear`) — long-standing Emby/Jellyfin
convention, same ones the old plugin's control panel used, not
**Wrong on the first guess, fixed once the real assemblies were available:** independently re-confirmed for this Emby version.
- **Namespaces**, not names: `PlaybackProgressEventArgs`/`PlaybackStopEventArgs` - **`X-Emby-Authorization` header format** — same caveat as above.
live in `MediaBrowser.Controller.Library`, not `...Session`.
`IAuthorizationContext`/`AuthorizationInfo` live in `MediaBrowser.Controller.Net`.
- **ID types**: item/user/session ids (`SessionInfo.Id`, `SessionInfo.UserId`,
`BaseItemDto.Id`) are plain **`string`**, not `Guid`, on this SDK version.
`Party`/`PartyMember` were changed from `Guid` to `string` accordingly.
(`Party.Id` itself stays a `Guid` — that's an id this plugin mints, not an
Emby one.)
- **Reading the calling user is a two-step, not one property**:
`IAuthorizationContext.GetAuthorizationInfo(Request).UserId` is a `long`
(an internal row id — not the id used anywhere else). The one that matches
`SessionInfo.UserId` etc. is `.User.Id` — except that one comes back as a
`Guid`, so `WatchPartyService.CallerUserId()` reformats it with
`.ToString("N")` to match the string convention used elsewhere. See the
comment there.
**Still only compile-checked, not verified against a running server**
(nothing here fails to build — these are runtime behaviors the compiler
can't check):
- Whether `.ToString("N")` (no dashes) is really the right string format to
match `SessionInfo.UserId` — plausible, matches Emby/Jellyfin convention
elsewhere, not observed directly.
- HTTP error mapping — endpoints throw plain
`ArgumentException`/`InvalidOperationException`; Emby likely has a more
specific exception type that maps to a proper 404/400 instead of a
generic 500.
- Static file content types — `GET /WatchParty/app` etc. return a bare
string; `IHasResultFactory` looks like the right extension point for a
real `Content-Type`/caching headers, not wired up yet.
- `controllingSessionId` passed as `null` to `SendPlaystateCommand` — compiles,
unknown whether Emby's runtime wants a real session/user there instead.
- `X-Emby-Authorization` header fields and `/Users/AuthenticateByName`'s `Pw`
field name in `web/control-panel/app.js` — it's JS, nothing compiles it;
long-standing Emby/Jellyfin convention, not exercised against a real server.
- The dashboard settings page (`configPage.html`) actually rendering/saving
correctly — embedding compiles, but the page's own JS (`ApiClient`,
`Dashboard` globals) only exists inside a running Emby dashboard.
- **`partyPage.html`**: whether `ApiClient.ajax({...})` really behaves like
the jQuery-style options object it's called with here (`type`, `url`,
`dataType`) — that's the long-standing Emby/Jellyfin web client convention,
not confirmed against this specific build.
- Whether an automatic menu link ever actually appears anywhere for
`EnableInUserMenu`/`MenuIcon = "group"`. As of this writing the page is
reachable and crash-free (see below) but no menu entry has been spotted
yet — worth checking `MenuSection` next if it still doesn't show up
(left unset so far; the one confirmed real-world example didn't set it
either, but that example also didn't use `EnableInMainMenu`, which turned
out to matter for something else - see below).
**Real bug found and fixed by reading the target server's actual client code**
(not from any doc — see git history for the two commits this produced):
navigating to the party page threw inside Emby's own `viewmanager.js`
(v4.10.0.40): `TypeError: Cannot read properties of undefined (reading
'lifeCycleAbortSignal')`. Fetching that file directly from the running
server (it's unauthenticated static JS) showed why: a view only gets a
`view.controller` — required before ViewManager will call any lifecycle
method on it — if its root element has a `data-controller` attribute
resolving to an AMD module. `configPage.html` never needed this because
admin dashboard pages apparently go through an older/separate loading path;
a page reached through `EnableInUserMenu`/`EnableInMainMenu` does not get
that same leniency. Fixed by adding
`data-controller="__plugin/WatchPartyRoomController"` to `partyPage.html`
and shipping a third, minimal `PluginPageInfo` (`partyPageController.js`) for
it to resolve to — see the comments in `Plugin.cs` and
`Configuration/partyPageController.js`.
Also checked directly (via a JS probe against the live server, not a guess):
`pluginmanager.js` has a hardcoded GUID allowlist gating which plugins get a
page at all (`allowPluginPages`), but it's skipped entirely whenever
`appHost.supports('restrictedplugins')` is falsy — confirmed falsy on this
server's web client, so the allowlist is not what's blocking anything here.
It's presumably what actually blocks arbitrary plugins on official mobile
apps (matches the forum thread cited above).
The remaining way to close these out: install the plugin DLL on a real Emby
Server 4.9.x and exercise it with two accounts.
## Known limitations ## Known limitations
- Sync is best-effort and loose by design (see "Non-goals"). Don't expect - Search-only catalog (see Non-goals) — no way to browse a show's episode
frame-accuracy. list yet.
- Depends on the member's *own* client actually honoring remote Seek/Pause - One item per party; no shared queue.
commands smoothly — most official Emby apps do (it's how casting/remote - A party member needs a browser tab open and connected for sync to apply —
control works today), but behavior can vary by platform, and a Seek can there's no "catch up automatically when you come back" beyond whatever
cause a visible re-buffer. the next heartbeat corrects.
- One item per party; no shared "up next" queue. - No persistence: server restart or long idle time ends every party.
- No chat/reactions in v1 (kept out on purpose — see the design conversation
that led to this repo).
- A user with two simultaneously active sessions on the same item is
resolved by "most recently active", which can guess wrong.

2
server/.dockerignore Normal file
View file

@ -0,0 +1,2 @@
node_modules
npm-debug.log

8
server/.env.example Normal file
View file

@ -0,0 +1,8 @@
# Copy to .env (or set these as real environment variables / Docker env)
# before starting the server.
# Base URL of your Emby Server - no trailing slash.
EMBY_SERVER_URL=https://theater.media.kyuno.fr
# Port this app listens on.
PORT=3000

17
server/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
# Build context must be the repo root (not server/), since this also needs
# the client/ folder: docker build -f server/Dockerfile -t watchparty .
FROM node:20-alpine
WORKDIR /app
COPY server/package.json server/package-lock.json* ./server/
RUN cd server && npm install --omit=dev
COPY server ./server
COPY client ./client
WORKDIR /app/server
ENV PORT=3000
EXPOSE 3000
CMD ["node", "src/index.js"]

869
server/package-lock.json generated Normal file
View file

@ -0,0 +1,869 @@
{
"name": "watchparty-server",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "watchparty-server",
"version": "0.1.0",
"dependencies": {
"express": "^4.19.2",
"ws": "^8.18.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
"integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.16.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/body-parser/node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

18
server/package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "watchparty-server",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node src/index.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"express": "^4.19.2",
"ws": "^8.18.0"
}
}

46
server/src/embyAuth.js Normal file
View file

@ -0,0 +1,46 @@
'use strict';
const EMBY_SERVER_URL = process.env.EMBY_SERVER_URL || '';
if (!EMBY_SERVER_URL) {
console.warn(
'[watchparty] EMBY_SERVER_URL is not set - Emby token validation will always fail. ' +
'Set it to your Emby server base URL, e.g. https://theater.media.kyuno.fr'
);
}
/**
* Confirms an Emby access token really grants access to the given user id,
* by asking the Emby server itself (GET /Users/{userId}). This backend never
* sees passwords, and never stores a token beyond the lifetime of a single
* request - it only ever forwards a token the browser already obtained
* directly from Emby's own /Users/AuthenticateByName.
*
* TODO(verify): /Users/{id} is long-standing, core Emby API - a safer bet
* than assuming a possible /Users/Me shortcut exists on this server version.
* This hits Emby on every party create/join/ws-auth call; add caching here
* if that ever becomes a problem in practice.
*/
async function resolveEmbyUser(token, userId) {
if (!token || !userId || !EMBY_SERVER_URL) {
return null;
}
try {
const res = await fetch(`${EMBY_SERVER_URL.replace(/\/+$/, '')}/Users/${encodeURIComponent(userId)}`, {
headers: { 'X-Emby-Token': token }
});
if (!res.ok) {
return null;
}
const user = await res.json();
return { id: user.Id, name: user.Name };
} catch (err) {
console.error('[watchparty] Emby token validation failed:', err.message);
return null;
}
}
module.exports = { resolveEmbyUser, EMBY_SERVER_URL };

83
server/src/index.js Normal file
View file

@ -0,0 +1,83 @@
'use strict';
const path = require('path');
const http = require('http');
const express = require('express');
const { WebSocketServer } = require('ws');
const { resolveEmbyUser, EMBY_SERVER_URL } = require('./embyAuth');
const { PartyManager } = require('./partyManager');
const { attachWebSocketServer } = require('./wsHandler');
const app = express();
app.use(express.json());
// The frontend is served from the same origin as this API, so none of these
// routes need CORS. The browser's OWN calls to Emby itself (catalog, images,
// video) are a different story - see docs/architecture.md.
app.use(express.static(path.join(__dirname, '..', '..', 'client')));
app.get('/api/config', (_req, res) => {
res.json({ embyServerUrl: EMBY_SERVER_URL || null });
});
function auth(req, res, next) {
const token = (req.get('X-Emby-Token') || '').trim();
const userId = (req.get('X-Emby-UserId') || '').trim();
resolveEmbyUser(token, userId).then((user) => {
if (!user) {
res.status(401).json({ error: 'Session Emby invalide ou expirée.' });
return;
}
req.embyUser = user;
next();
});
}
app.post('/api/parties', auth, (req, res) => {
const itemId = req.body && req.body.itemId;
if (!itemId) {
res.status(400).json({ error: 'itemId is required.' });
return;
}
const party = PartyManager.create(req.embyUser.id, req.embyUser.name, itemId);
res.json(PartyManager.toPublicState(party));
});
app.post('/api/parties/:joinCode/join', auth, (req, res) => {
const party = PartyManager.getByJoinCode(req.params.joinCode);
if (!party) {
res.status(404).json({ error: 'Aucune party avec ce code (elle est peut-être terminée).' });
return;
}
PartyManager.join(party, req.embyUser.id, req.embyUser.name);
res.json(PartyManager.toPublicState(party));
});
app.post('/api/parties/:partyId/leave', auth, (req, res) => {
const party = PartyManager.getById(req.params.partyId);
if (party) {
PartyManager.leave(party, req.embyUser.id);
}
res.json({ ok: true });
});
app.get('/api/parties/:partyId', auth, (req, res) => {
const party = PartyManager.getById(req.params.partyId);
if (!party) {
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
return;
}
res.json(PartyManager.toPublicState(party));
});
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
attachWebSocketServer(wss);
setInterval(() => PartyManager.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
const port = process.env.PORT || 3000;
server.listen(port, () => {
console.log(`[watchparty] listening on :${port} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
});

121
server/src/partyManager.js Normal file
View file

@ -0,0 +1,121 @@
'use strict';
const crypto = require('crypto');
// No 0/O/1/I - easy to read aloud / type on a phone.
const JOIN_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
function generateJoinCode() {
const bytes = crypto.randomBytes(6);
let code = '';
for (let i = 0; i < 6; i++) {
code += JOIN_CODE_ALPHABET[bytes[i] % JOIN_CODE_ALPHABET.length];
}
return code;
}
/**
* In-memory party store - mirrors the Party/PartyMember/PartyManager design
* from the earlier Emby-plugin attempt (see git history / docs/architecture.md),
* adapted for a model where we own the player: one host drives playback,
* everyone else's client just applies whatever the host reports. Nothing is
* persisted to disk; a server restart clears every party, which is fine - a
* party is meant to live for one watch session, not persist for weeks.
*/
class PartyManager {
constructor() {
this.partiesById = new Map();
this.partyIdByJoinCode = new Map();
}
create(hostUserId, hostUserName, itemId) {
const party = {
id: crypto.randomUUID(),
joinCode: generateJoinCode(),
itemId,
hostUserId,
createdUtc: Date.now(),
lastActivityUtc: Date.now(),
// userId -> { userId, userName, connection, isPaused, positionTicks, lastUpdatedUtc }
members: new Map()
};
party.members.set(hostUserId, this._newMember(hostUserId, hostUserName));
this.partiesById.set(party.id, party);
this.partyIdByJoinCode.set(party.joinCode, party.id);
return party;
}
getById(partyId) {
return this.partiesById.get(partyId) || null;
}
getByJoinCode(joinCode) {
const id = this.partyIdByJoinCode.get((joinCode || '').toUpperCase());
return id ? this.getById(id) : null;
}
join(party, userId, userName) {
const member = this._newMember(userId, userName);
party.members.set(userId, member);
party.lastActivityUtc = Date.now();
return member;
}
leave(party, userId) {
party.members.delete(userId);
party.lastActivityUtc = Date.now();
if (party.hostUserId === userId) {
// Host left: promote whoever's left (arbitrary but deterministic-ish;
// good enough for v1 - matches the old plugin's reference-fallback logic).
const next = party.members.keys().next();
party.hostUserId = next.done ? null : next.value;
}
if (party.members.size === 0) {
this.partiesById.delete(party.id);
this.partyIdByJoinCode.delete(party.joinCode);
}
}
removeIdleParties(idleTimeoutMs) {
const cutoff = Date.now() - idleTimeoutMs;
for (const party of this.partiesById.values()) {
if (party.lastActivityUtc < cutoff) {
this.partiesById.delete(party.id);
this.partyIdByJoinCode.delete(party.joinCode);
}
}
}
toPublicState(party) {
return {
partyId: party.id,
joinCode: party.joinCode,
itemId: party.itemId,
hostUserId: party.hostUserId,
members: Array.from(party.members.values()).map((m) => ({
userId: m.userId,
userName: m.userName,
isHost: m.userId === party.hostUserId,
isPaused: m.isPaused,
positionTicks: m.positionTicks,
connected: !!m.connection
}))
};
}
_newMember(userId, userName) {
return {
userId,
userName,
connection: null,
isPaused: true,
positionTicks: 0,
lastUpdatedUtc: Date.now()
};
}
}
module.exports = { PartyManager: new PartyManager() };

112
server/src/wsHandler.js Normal file
View file

@ -0,0 +1,112 @@
'use strict';
const { resolveEmbyUser } = require('./embyAuth');
const { PartyManager } = require('./partyManager');
/**
* Realtime side of a party: a client connects, authenticates with its Emby
* token (as the FIRST message, not a query string - see client/js/party.js),
* and from then on either drives playback (if it's the host) or receives it.
*
* Protocol (all messages are JSON):
* -> {type:"auth", token, userId, partyId}
* <- {type:"members", members:[...]} (on any join/leave)
* -> {type:"playstate", isPaused, positionTicks} (host only - ignored otherwise)
* <- {type:"playstate", isPaused, positionTicks} (relayed to everyone but the host)
* -> {type:"leave"}
* <- {type:"error", message}
*/
function attachWebSocketServer(wss) {
wss.on('connection', (ws) => {
let party = null;
let userId = null;
ws.on('message', async (raw) => {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch (err) {
return;
}
if (msg.type === 'auth') {
const user = await resolveEmbyUser(msg.token, msg.userId);
if (!user) {
send(ws, { type: 'error', message: 'Session Emby invalide ou expirée.' });
ws.close();
return;
}
const p = PartyManager.getById(msg.partyId);
const member = p && p.members.get(user.id);
if (!p || !member) {
send(ws, { type: 'error', message: "Tu n'es pas membre de cette party." });
ws.close();
return;
}
party = p;
userId = user.id;
member.connection = ws;
broadcastMembers(party);
return;
}
if (!party || !userId) {
return; // ignore anything before a successful auth
}
if (msg.type === 'playstate') {
if (userId !== party.hostUserId) {
return; // only the host drives playback - see docs/architecture.md
}
const member = party.members.get(userId);
member.isPaused = !!msg.isPaused;
member.positionTicks = Number(msg.positionTicks) || 0;
member.lastUpdatedUtc = Date.now();
party.lastActivityUtc = Date.now();
broadcast(party, { type: 'playstate', isPaused: member.isPaused, positionTicks: member.positionTicks }, ws);
return;
}
if (msg.type === 'leave') {
PartyManager.leave(party, userId);
broadcastMembers(party);
ws.close();
}
});
ws.on('close', () => {
if (party && userId) {
PartyManager.leave(party, userId);
broadcastMembers(party);
}
});
});
}
function send(ws, message) {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(message));
}
}
function broadcast(party, message, exceptWs) {
for (const member of party.members.values()) {
if (member.connection && member.connection !== exceptWs) {
send(member.connection, message);
}
}
}
function broadcastMembers(party) {
const members = Array.from(party.members.values()).map((m) => ({
userId: m.userId,
userName: m.userName,
isHost: m.userId === party.hostUserId,
connected: !!m.connection
}));
broadcast(party, { type: 'members', members }, null);
}
module.exports = { attachWebSocketServer };