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:
parent
f25f719e5f
commit
6ff9aa29e2
24 changed files with 2200 additions and 333 deletions
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal 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
4
.gitignore
vendored
|
|
@ -9,8 +9,10 @@ obj/
|
|||
.idea/
|
||||
*.swp
|
||||
|
||||
## Node (only relevant if control-panel tooling is ever added)
|
||||
## Node (server/)
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
|
||||
## OS cruft
|
||||
.DS_Store
|
||||
|
|
|
|||
129
README.md
129
README.md
|
|
@ -1,97 +1,94 @@
|
|||
# Watch Party for Emby
|
||||
|
||||
An Emby Server plugin that lets a group of users on the same server watch the
|
||||
same title together. Nobody switches players: everyone keeps using their
|
||||
normal Emby app (web, mobile, TV, whatever), and the plugin nudges playback
|
||||
back in sync using the same remote-control commands Emby already uses for
|
||||
casting / "play on another device".
|
||||
A standalone web app for watching the same title, in sync, with other users
|
||||
of the same Emby server. It reads your Emby catalog and streams your Emby
|
||||
media, but it's its own app with its own player — not an Emby plugin.
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design,
|
||||
including which parts are solidly confirmed against Emby's SDK docs and which
|
||||
still need verification against your exact server build.
|
||||
including why this replaced an earlier Emby-plugin attempt (still in
|
||||
[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.
|
||||
2. In Emby's web client (or the Electron desktop app), open **Watch Party**
|
||||
from the menu and start a party. You get a 6-character join code. No
|
||||
separate login — it's a real page inside Emby, using your existing session.
|
||||
3. Friends open the same **Watch Party** menu item, start playing the *same*
|
||||
title in their own Emby app, and enter the code to join.
|
||||
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).
|
||||
1. Sign in with your Emby account.
|
||||
2. Search your Emby catalog for a title.
|
||||
3. Start a party (you become the host) or join one with a 6-character code.
|
||||
4. The host's play/pause/seek drives everyone else's player automatically,
|
||||
over a small backend that only ever coordinates timing — actual video
|
||||
always streams directly from your Emby server to each viewer's browser.
|
||||
|
||||
## Status
|
||||
|
||||
Builds clean (0 errors, 0 warnings) against the real `MediaBrowser.Server.Core`
|
||||
/ `MediaBrowser.Common` 4.9.1.90 assemblies from NuGet, and is installed on a
|
||||
real Emby Server (Docker) — the plugin loads and its admin page shows up.
|
||||
The actual feature (`partyPage.html`, reached via `EnableInUserMenu`) hasn't
|
||||
been exercised live yet. See
|
||||
[docs/architecture.md](docs/architecture.md#assumptions-to-verify) for
|
||||
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)
|
||||
Freshly scaffolded and smoke-tested (server starts, routes render, error
|
||||
handling works — see git history) with a stubbed Emby server. **Not yet run
|
||||
against a real Emby server.** The biggest open question is CORS: the browser
|
||||
calls Emby directly from a different origin, which needs Emby (or its
|
||||
reverse proxy) to answer that cross-origin requests are allowed — see
|
||||
[docs/architecture.md](docs/architecture.md#assumptions-to-verify).
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/WatchParty/ the plugin itself (C#, netstandard2.0)
|
||||
Plugin.cs plugin registration + both plugin pages
|
||||
Configuration/ plugin settings, the admin settings page, and
|
||||
partyPage.html - the actual user-facing feature,
|
||||
integrated into Emby's own menu (not a separate app)
|
||||
Parties/ Party / PartyMember model + in-memory PartyManager
|
||||
Sync/ the drift-correction background loop
|
||||
Api/ REST endpoints, called from partyPage.html
|
||||
docs/architecture.md full design doc
|
||||
server/ Node.js backend: party/room coordination + WebSocket sync.
|
||||
Never touches media bytes - only validates Emby tokens
|
||||
(by asking Emby itself) and relays playback state.
|
||||
src/index.js Express app + REST endpoints
|
||||
src/wsHandler.js WebSocket protocol (see comments in the file)
|
||||
src/partyManager.js in-memory room state
|
||||
src/embyAuth.js token validation against your Emby server
|
||||
|
||||
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
|
||||
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
|
||||
folder, set `EMBY_PLUGINS_DIR` before building, e.g.:
|
||||
Then open `http://localhost:3000`.
|
||||
|
||||
## Deploying
|
||||
|
||||
Build the Docker image from the **repo root** (it needs both `server/` and
|
||||
`client/`):
|
||||
|
||||
```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.
|
||||
portable install vs. Docker volume).
|
||||
Then put it behind your existing reverse proxy like everything else
|
||||
(`theater.media.kyuno.fr`, `git.dev.kyuno.fr`, ...), e.g.
|
||||
`watchparty.kyuno.fr` → `http://<container>:3000`.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] Design doc + scaffold
|
||||
- [x] Build against the real SDK, fix whatever the `TODO(verify)` spots got wrong
|
||||
- [x] Install on a real Emby Server (Docker)
|
||||
- [x] Integrate into Emby's own UI instead of a standalone web app
|
||||
(`EnableInUserMenu` — see [docs/architecture.md](docs/architecture.md#components))
|
||||
- [x] Design + scaffold the standalone app
|
||||
- [x] Smoke-test locally (routing, error handling) without a real Emby server
|
||||
- [ ] Verify against the real Emby server (CORS is the main risk - see above)
|
||||
- [ ] Try a full party end-to-end with two accounts
|
||||
- [ ] Live (WebSocket) updates in the party page instead of polling
|
||||
- [ ] Per-party override of sync settings instead of only server-wide defaults
|
||||
- [ ] TV show navigation (seasons/episodes) beyond flat search
|
||||
- [ ] 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
|
||||
|
||||
|
|
|
|||
176
client/css/app.css
Normal file
176
client/css/app.css
Normal 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
14
client/index.html
Normal 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
24
client/js/app.js
Normal 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
110
client/js/embyApi.js
Normal 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
75
client/js/party.js
Normal 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
44
client/js/router.js
Normal 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
59
client/js/views/browse.js
Normal 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;
|
||||
}
|
||||
58
client/js/views/itemDetail.js
Normal file
58
client/js/views/itemDetail.js
Normal 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">← 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
46
client/js/views/login.js
Normal 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
102
client/js/views/player.js
Normal 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
10
docker-compose.yml
Normal 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"
|
||||
|
|
@ -2,292 +2,155 @@
|
|||
|
||||
## Goals
|
||||
|
||||
- Watch the same title, at (roughly) the same time, as other users on the
|
||||
same Emby server.
|
||||
- Don't require anyone to change how they watch — no new client, no browser
|
||||
extension, no modified apps. People keep using whatever Emby app they
|
||||
already use.
|
||||
- Ship as a single Emby Server plugin.
|
||||
- Watch the same title, in sync, with other users of the same Emby server.
|
||||
- Use Emby as the media source (catalog + streaming) without requiring
|
||||
changes to Emby itself or its official clients.
|
||||
- Access gated to people with an Emby account on this server — no anonymous
|
||||
use, no separate identity system.
|
||||
|
||||
## Non-goals (for v1)
|
||||
## Non-goals
|
||||
|
||||
- Frame-accurate sync. A few seconds of drift between corrections is
|
||||
acceptable — that's inherent to the "generic remote control" approach (see
|
||||
below), and is a conscious tradeoff, not an oversight.
|
||||
- A shared/forced "host controls everyone" model. The chosen model (see
|
||||
"Control model" below) is: everyone keeps their own play/pause/seek
|
||||
controls; the party only shares a reference position to resync against.
|
||||
- Cross-server parties, chat, reactions. Possible later, not in v1 — see
|
||||
Roadmap in the README.
|
||||
- Chat/reactions — deliberately out of scope.
|
||||
- OIDC/SSO — an OIDC provider (LemonLDAP::NG) is available in principle, but
|
||||
the explicit choice was Emby-only auth: simpler, no extra infra dependency,
|
||||
and everyone already has an Emby account.
|
||||
- Full library navigation (Shows → Seasons → Episodes) in v1 — search-based
|
||||
catalog only, to keep the first version achievable. Revisit if browsing
|
||||
turns out to matter more than searching in practice.
|
||||
|
||||
## 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
|
||||
another device" style features: `ISessionManager` tracks every active
|
||||
session's playback state, and a session can be commanded via
|
||||
`SendPlaystateCommand` (`Play`, `Pause`, `Unpause`, `Seek`, ...) or
|
||||
`SendGeneralCommand`. This already exists in Emby Server and, to whatever
|
||||
extent each client supports being remote-controlled at all (most official
|
||||
clients do, since it's how casting/remote-play works), in the clients too.
|
||||
The first attempt (still in [`src/WatchParty/`](../src/WatchParty/), kept for
|
||||
reference) was a real Emby Server plugin: sync via `ISessionManager`'s
|
||||
existing remote-control commands, a settings page via `IHasWebPages`, and —
|
||||
after a fair amount of reverse-engineering — a real user-facing page
|
||||
integrated into Emby's own web client menu (`PluginPageInfo.EnableInUserMenu`,
|
||||
undocumented but confirmed working; required a `data-controller` AMD module
|
||||
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
|
||||
coordinator sitting on top of that existing mechanism:
|
||||
That plugin builds and installs correctly. What it hit a hard wall on: **a
|
||||
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".
|
||||
2. Watch each member's reported position/pause state (the same data Emby's
|
||||
own "Now Playing" / dashboard already reads).
|
||||
3. When a member drifts too far from the party's reference position, or its
|
||||
pause state disagrees with the reference, send the same kind of
|
||||
Seek/Pause/Unpause command Emby would send for ordinary remote control.
|
||||
Building a standalone app sidesteps the question entirely: it's our own
|
||||
player, so there's no "how do I add a button to someone else's UI" problem
|
||||
in the first place, and sync can be tighter than nudging Emby's
|
||||
remote-control commands ever allowed (see below).
|
||||
|
||||
Sources consulted while designing this:
|
||||
- [How to build a Server Plugin](https://github.com/MediaBrowser/Emby/wiki/How-to-build-a-Server-Plugin) — project setup, `BasePlugin<T>`, `IServerEntryPoint`.
|
||||
- [Creating Api Endpoints](https://github.com/MediaBrowser/Emby/wiki/Creating-Api-Endpoints) — the `IService`/`[Route]`/`IReturn<T>` REST pattern.
|
||||
- [Emby plugin dev docs](https://dev.emby.media/doc/plugins/dev/index.html) — confirms `netstandard2.0`, .NET Core/Mono runtimes.
|
||||
- [`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.
|
||||
Full trace of the plugin investigation (real errors, real fixes, sources
|
||||
consulted) is in the git history of this file and of `src/WatchParty/` —
|
||||
worth reading if a future Emby SDK update ever makes in-player integration
|
||||
possible and this gets revisited.
|
||||
|
||||
## Components
|
||||
|
||||
```
|
||||
src/WatchParty/
|
||||
Plugin.cs BasePlugin<PluginConfiguration> + both plugin pages (see GetPages())
|
||||
Configuration/
|
||||
PluginConfiguration.cs defaults (drift threshold, cooldown, etc.)
|
||||
configPage.html admin settings page (Dashboard > Plugins > Watch Party)
|
||||
partyPage.html the actual feature: create/join, member list, resync/leave -
|
||||
EnableInUserMenu=true, so regular users see it too
|
||||
Parties/
|
||||
Party.cs party id, join code, item id, host/reference user, members
|
||||
PartyMember.cs per-member session id + last known position/pause + EstimatedPositionTicks
|
||||
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
|
||||
server/ Node.js + Express + ws. Owns party/room state and the realtime
|
||||
sync channel. Never touches a media byte - see "Media never
|
||||
flows through our backend" below.
|
||||
|
||||
client/ Static frontend, plain JS + ES modules, no build step (same bias
|
||||
as the earlier plugin's control panel). Talks to two different
|
||||
things and must not confuse them:
|
||||
- Emby directly (embyApi.js): catalog, images, streaming.
|
||||
- Our own backend (party.js): create/join/leave a party, and
|
||||
the WebSocket for realtime sync.
|
||||
```
|
||||
|
||||
Parties are **in-memory only** — a server restart clears them. That's fine
|
||||
for v1: a party is meant to live for one watch session, not persist for
|
||||
weeks.
|
||||
### Auth
|
||||
|
||||
The party UI is a real Emby plugin page (`Configuration/partyPage.html`,
|
||||
registered in `Plugin.cs` via `IHasWebPages.GetPages()`), not a separate
|
||||
web app. `PluginPageInfo` turns out to support exactly this: setting
|
||||
`EnableInUserMenu = true` (`Plugin.cs`) surfaces the page to regular users, not
|
||||
just admins — the same mechanism as the admin settings page
|
||||
(`configPage.html`), just with different visibility flags. This was not
|
||||
obvious from the official docs (which only cover the admin/dashboard case) —
|
||||
confirmed instead via reflection on the real `PluginPageInfo` type (it has
|
||||
`EnableInMainMenu`, `EnableInUserMenu`, `MenuSection`, `MenuIcon`,
|
||||
`IsMainConfigPage`) and an Emby community thread showing another plugin using
|
||||
`EnableInUserMenu = true` in practice.
|
||||
Emby-only, as decided. The browser calls Emby's own
|
||||
`POST /Users/AuthenticateByName` directly (same call the old plugin's
|
||||
control panel used) and keeps the resulting `AccessToken`/`User.Id` in
|
||||
`localStorage`. Our backend never sees a password — only ever a token the
|
||||
browser already obtained from Emby, sent as a header (`X-Emby-Token` /
|
||||
`X-Emby-UserId`) on REST calls, or as the first WebSocket message (not a
|
||||
query string — tokens don't belong in URLs). The backend validates a token
|
||||
by asking Emby itself (`GET /Users/{userId}` with that token) rather than
|
||||
trying to verify anything cryptographically — see `server/src/embyAuth.js`.
|
||||
|
||||
Two consequences of doing it this way instead of a standalone page:
|
||||
- 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.
|
||||
### Media never flows through our backend
|
||||
|
||||
An earlier version of this plugin shipped a fully standalone web app
|
||||
(`web/control-panel/`, its own login form, served via custom
|
||||
`GET /WatchParty/app` routes) to sidestep exactly this uncertainty. It was
|
||||
removed once `EnableInUserMenu` was confirmed to work — see git history if
|
||||
you ever need the standalone version back (e.g. to reach a client where the
|
||||
integrated page doesn't show up).
|
||||
`client/js/embyApi.js` builds Emby URLs (images, HLS stream) directly, and
|
||||
the browser fetches them straight from Emby. `server/` only ever sees JSON
|
||||
party-coordination traffic and a handful of bytes per WebSocket message. The
|
||||
one deliberate exception: `<img>`/`<video>` elements can't send custom
|
||||
headers, so their URLs carry the Emby token as an `api_key` query parameter
|
||||
— 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
|
||||
|
||||
Update: the .NET SDK is now installed and `src/WatchParty` **builds clean
|
||||
(0 errors, 0 warnings) against the real `MediaBrowser.Server.Core` /
|
||||
`MediaBrowser.Common` 4.9.1.90 assemblies**, restored from NuGet. Most of the
|
||||
list below turned out right on the first guess; a few didn't, and were fixed
|
||||
by reading the actual assemblies (via .NET reflection) and then letting the
|
||||
compiler confirm. Keeping the history here since it's a useful map of where
|
||||
this SDK surprises you.
|
||||
Unlike the plugin (which got to compile against Emby's real SDK assemblies),
|
||||
none of the Emby REST API assumptions below have been exercised against a
|
||||
real server yet — only smoke-tested against a stubbed/fake Emby URL to
|
||||
confirm the *app's own* code paths (routing, error handling) don't crash
|
||||
(see git history). Check these first when testing for real:
|
||||
|
||||
**Confirmed correct as originally written** (compiled with no changes needed):
|
||||
- `BasePlugin<T>` constructor shape, `IServerEntryPoint`, `netstandard2.0` +
|
||||
`MediaBrowser.Server.Core` package — all from the official wiki.
|
||||
- `IHasWebPages` (not `IHasUIPages` — that name does exist too, in
|
||||
`MediaBrowser.Model.Plugins.UI`, but `IHasWebPages` in
|
||||
`MediaBrowser.Model.Plugins` is the one that's actually wired up here) and
|
||||
`PluginPageInfo.Name`/`.EmbeddedResourcePath`.
|
||||
- `ISessionManager.SendPlaystateCommand(string controllingSessionId, string
|
||||
sessionId, PlaystateRequest, CancellationToken)`, `SendGeneralCommand`, and
|
||||
`PlaystateRequest { Command, SeekPositionTicks }` / `PlaystateCommand.{Pause,
|
||||
Unpause, Seek}`.
|
||||
- `ILogManager.GetLogger(name)` / `ILogger.Debug(...)` /
|
||||
`.ErrorException(...)` — still the live pattern on 4.9.x, not replaced by
|
||||
`Microsoft.Extensions.Logging`.
|
||||
- `IRequiresRequest` / `IService` / `[Route]` / `IReturn<T>` for the REST
|
||||
endpoints.
|
||||
- `SessionInfo.PlayState.PositionTicks` (`long?`) and `.IsPaused` (`bool`).
|
||||
|
||||
**Wrong on the first guess, fixed once the real assemblies were available:**
|
||||
- **Namespaces**, not names: `PlaybackProgressEventArgs`/`PlaybackStopEventArgs`
|
||||
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.
|
||||
- **CORS** — the browser calls Emby from a different origin than this app.
|
||||
If Emby (or the openresty reverse proxy in front of it) doesn't answer
|
||||
preflight requests permissively, every direct call (including login)
|
||||
fails with a browser-level CORS error, not a normal HTTP error. Open the
|
||||
browser console on first test; if you see "CORS" anywhere, the fix is
|
||||
routing `embyApi.js`'s JSON calls through `server/` as a proxy instead of
|
||||
direct — video/image `src` URLs wouldn't need this (no CORS for those).
|
||||
- **`/Users/{userId}` for token validation** — chosen deliberately over a
|
||||
guessed `/Users/Me` shortcut (not confirmed to exist on this Emby
|
||||
version), but not confirmed to behave as expected either.
|
||||
- **`/Videos/{id}/master.m3u8`** for HLS playback, with
|
||||
`VideoCodec=h264&AudioCodec=aac` forcing a broadly-compatible transcode
|
||||
target. Works for basically anything Emby can transcode, but is never the
|
||||
most efficient path (no direct play, no bitrate/quality negotiation) —
|
||||
fine for v1, worth revisiting once the basic flow works.
|
||||
- **Response field names** (`AccessToken`, `User.Id`, `User.Name`,
|
||||
`Items[].Id/Name/Overview/ProductionYear`) — long-standing Emby/Jellyfin
|
||||
convention, same ones the old plugin's control panel used, not
|
||||
independently re-confirmed for this Emby version.
|
||||
- **`X-Emby-Authorization` header format** — same caveat as above.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Sync is best-effort and loose by design (see "Non-goals"). Don't expect
|
||||
frame-accuracy.
|
||||
- Depends on the member's *own* client actually honoring remote Seek/Pause
|
||||
commands smoothly — most official Emby apps do (it's how casting/remote
|
||||
control works today), but behavior can vary by platform, and a Seek can
|
||||
cause a visible re-buffer.
|
||||
- One item per party; no shared "up next" queue.
|
||||
- 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.
|
||||
- Search-only catalog (see Non-goals) — no way to browse a show's episode
|
||||
list yet.
|
||||
- One item per party; no shared queue.
|
||||
- A party member needs a browser tab open and connected for sync to apply —
|
||||
there's no "catch up automatically when you come back" beyond whatever
|
||||
the next heartbeat corrects.
|
||||
- No persistence: server restart or long idle time ends every party.
|
||||
|
|
|
|||
2
server/.dockerignore
Normal file
2
server/.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
npm-debug.log
|
||||
8
server/.env.example
Normal file
8
server/.env.example
Normal 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
17
server/Dockerfile
Normal 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
869
server/package-lock.json
generated
Normal 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
18
server/package.json
Normal 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
46
server/src/embyAuth.js
Normal 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
83
server/src/index.js
Normal 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
121
server/src/partyManager.js
Normal 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
112
server/src/wsHandler.js
Normal 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 };
|
||||
Loading…
Reference in a new issue