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