watchparty/client/js/embyApi.js
Nicolas 6ff9aa29e2 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>
2026-09-13 20:48:55 +02:00

110 lines
4.1 KiB
JavaScript

// 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()}`;
}