// 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) { // 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: