0.1.38 gateway
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/* The console's one HTTP client.
|
||||
*
|
||||
* Everything it needs to authenticate is already on the request: the admin token is an
|
||||
* HttpOnly cookie scoped to /admin, and the Emby-verified browser session is a second
|
||||
* cookie beside it. The console therefore never holds a credential, which is why there is
|
||||
* no token to store, refresh or accidentally log — and why an expired session is handled
|
||||
* by reloading into the gateway's own sign-in form rather than by anything here.
|
||||
*/
|
||||
|
||||
/** ApiError carries the status so a caller can tell "not configured" (404) from "broken". */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/* The sign-in behind this console lasts twelve hours and slides forward only for requests
|
||||
an operator actually caused, so the poll of a tab nobody is reading cannot keep it
|
||||
alive. Anything the console does while somebody is working it says so with this header;
|
||||
see operatorPresent on the gateway. */
|
||||
const ACTIVITY_WINDOW_MS = 5 * 60 * 1000;
|
||||
let lastInteraction = Date.now();
|
||||
for (const name of ['pointerdown', 'pointermove', 'keydown', 'wheel', 'scroll'] as const) {
|
||||
window.addEventListener(name, () => {
|
||||
lastInteraction = Date.now();
|
||||
}, { passive: true });
|
||||
}
|
||||
const operatorPresent = () => Date.now() - lastInteraction < ACTIVITY_WINDOW_MS;
|
||||
|
||||
/* A 401 is an expired sign-in rather than a wrong token. Reloading re-renders this URL as
|
||||
the gateway's login form with `next` pointing back at it, so the operator signs in once
|
||||
and lands where they were, instead of reading a banner the page can never clear. The
|
||||
timestamp is what stops a 401 that survives the reload from looping for ever. */
|
||||
const RELOGIN_KEY = 'memby-admin-relogin';
|
||||
function reauthenticate(): boolean {
|
||||
try {
|
||||
if (Date.now() - Number(sessionStorage.getItem(RELOGIN_KEY) ?? 0) < 30_000) return false;
|
||||
sessionStorage.setItem(RELOGIN_KEY, String(Date.now()));
|
||||
} catch {
|
||||
// Private-mode storage refusals must not cost the reload; take the loop risk.
|
||||
}
|
||||
window.location.reload();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(operatorPresent() ? { 'X-Memby-Admin-Active': '1' } : {}),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (response.status === 401) {
|
||||
throw new ApiError(
|
||||
reauthenticate()
|
||||
? 'Your sign-in has expired. Signing in again…'
|
||||
: 'Your sign-in has expired. Reload this page to sign in again.',
|
||||
401,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
throw new ApiError(body.error ?? `Request failed (${response.status})`, response.status);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
/** query turns a filter object into a query string, dropping anything unset.
|
||||
*
|
||||
* Dropping empties is what lets every page pass its whole filter state straight through:
|
||||
* a blank control means "any" on the server, and sending `user=` would narrow to rows
|
||||
* whose user is the empty string — which is none of them. */
|
||||
export function query(params: Record<string, string | number | boolean | undefined | null>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === '' || value === false) continue;
|
||||
search.set(key, String(value));
|
||||
}
|
||||
const encoded = search.toString();
|
||||
return encoded ? `?${encoded}` : '';
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) }),
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: 'PUT', body: body === undefined ? undefined : JSON.stringify(body) }),
|
||||
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
@@ -0,0 +1,413 @@
|
||||
/* The wire contract, as the console reads it.
|
||||
*
|
||||
* These mirror the Go structs in server/internal/api and server/internal/store. They are
|
||||
* hand-written rather than generated, deliberately: the console reads a subset of what the
|
||||
* gateway sends, and a generated file would carry every field of every response whether or
|
||||
* not a page uses it — which makes it impossible to tell, from this file, what the console
|
||||
* actually depends on.
|
||||
*
|
||||
* A field the console needs and the gateway does not send is a compile error here, which
|
||||
* is the point. A field the gateway sends and this omits is ignored, which is also the
|
||||
* point: an older console must keep working against a newer gateway. */
|
||||
|
||||
export interface KnownUser {
|
||||
id: string;
|
||||
username: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface DeviceVersion {
|
||||
version: string;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface KnownClient {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
username: string;
|
||||
version: string;
|
||||
protocol: string;
|
||||
capabilities: string[];
|
||||
lastSeen: string;
|
||||
versions: DeviceVersion[] | null;
|
||||
}
|
||||
|
||||
export interface LibraryStats {
|
||||
total: number;
|
||||
byType: Record<string, number> | null;
|
||||
lastSynced: string | null;
|
||||
}
|
||||
|
||||
export interface SyncRun {
|
||||
id: number;
|
||||
kind: string;
|
||||
trigger: string;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
itemsSeen: number;
|
||||
itemsUpserted: number;
|
||||
itemsRemoved: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ForYouStats {
|
||||
tracearrSessions: number;
|
||||
profiles: number;
|
||||
candidates: number;
|
||||
lastFullImport?: string | null;
|
||||
}
|
||||
|
||||
export interface Maintenance {
|
||||
enabled: boolean;
|
||||
message: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePolicy {
|
||||
enabled: boolean;
|
||||
latestVersion: string;
|
||||
minimumVersion: string;
|
||||
retireBelowVersion?: string;
|
||||
downloadUrl: string;
|
||||
sha256?: string;
|
||||
sizeBytes?: number;
|
||||
notes: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface RequestPolicy {
|
||||
allowedUserIds: string[] | null;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface PlaybackPolicy {
|
||||
prerollEnabled: boolean;
|
||||
prerollDurationMs: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface HeroItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export interface HeroPolicy {
|
||||
pinnedItems: HeroItem[] | null;
|
||||
primeSubtitle: string;
|
||||
schedules?: HeroSchedule[] | null;
|
||||
}
|
||||
|
||||
export interface RequestUsage {
|
||||
userId: string;
|
||||
requests: number;
|
||||
lastRequest?: string;
|
||||
}
|
||||
|
||||
export interface HeroSchedule {
|
||||
id: string;
|
||||
itemId: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
weekdays?: number[];
|
||||
priority: number;
|
||||
userId?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface MDBListSettings {
|
||||
enabled: boolean;
|
||||
apiKeyConfigured: boolean;
|
||||
sources: string[] | null;
|
||||
availableSources: string[] | null;
|
||||
cachedTitles: number;
|
||||
staleTitles: number;
|
||||
}
|
||||
|
||||
export interface SubtitleStoredStats {
|
||||
files?: number;
|
||||
bytes?: number;
|
||||
titles?: number;
|
||||
}
|
||||
|
||||
export interface SubtitleSettings {
|
||||
bazarrConfigured: boolean;
|
||||
bazarrEnabled: boolean;
|
||||
bazarrUrl?: string;
|
||||
openSubtitlesEnabled: boolean;
|
||||
openSubtitlesKeyConfigured: boolean;
|
||||
openSubtitlesAccount: boolean;
|
||||
openSubtitlesUsername?: string;
|
||||
featureEnabled: boolean;
|
||||
available: boolean;
|
||||
stored: SubtitleStoredStats;
|
||||
}
|
||||
|
||||
export interface Feature {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
area: string;
|
||||
defaultEnabled: boolean;
|
||||
minimumProtocol: number;
|
||||
capability: string;
|
||||
/** recovery is the sentence saying how a change takes effect — the thing an operator
|
||||
* most wants to know before flipping a switch on a household's televisions. */
|
||||
recovery: string;
|
||||
enabled: boolean;
|
||||
/** source is "default" or "override" — whether an operator has touched this one. */
|
||||
source: string;
|
||||
compatible: boolean;
|
||||
}
|
||||
|
||||
export interface FeaturePolicy {
|
||||
schemaVersion: number;
|
||||
revision: number;
|
||||
safeMode: boolean;
|
||||
updatedAt?: string;
|
||||
canRollback: boolean;
|
||||
features: Feature[] | null;
|
||||
}
|
||||
|
||||
export interface AdminStatus {
|
||||
serverVersion: string;
|
||||
maintenance: Maintenance;
|
||||
updatePolicy: UpdatePolicy;
|
||||
library: LibraryStats;
|
||||
syncRunning: boolean;
|
||||
runs: SyncRun[] | null;
|
||||
syncEvery: string;
|
||||
forYou: ForYouStats;
|
||||
forYouRunning: boolean;
|
||||
requestPolicy: RequestPolicy;
|
||||
playbackPolicy: PlaybackPolicy;
|
||||
heroPolicy: HeroPolicy;
|
||||
mdblist: MDBListSettings;
|
||||
subtitles: SubtitleSettings;
|
||||
features: FeaturePolicy;
|
||||
requestUsers: KnownUser[] | null;
|
||||
requestUsage: RequestUsage[] | null;
|
||||
clients: KnownClient[] | null;
|
||||
sonarrReady: boolean;
|
||||
radarrReady: boolean;
|
||||
}
|
||||
|
||||
export interface ViewsReport {
|
||||
today: { visits: number; viewers: number };
|
||||
lastWeek: { visits: number; viewers: number };
|
||||
daily: { label: string; visits: number; viewers: number }[];
|
||||
hourly: { label: string; visits: number; viewers: number }[];
|
||||
busiestHour: string;
|
||||
}
|
||||
|
||||
/* ---------- sign-in history ---------- */
|
||||
|
||||
export interface LoginEvent {
|
||||
id: number;
|
||||
occurredAt: string;
|
||||
embyUserId: string;
|
||||
username: string;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
clientVersion: string;
|
||||
clientProtocol: string;
|
||||
ipAddress: string;
|
||||
success: boolean;
|
||||
method: string;
|
||||
failureReason?: string;
|
||||
newDevice: boolean;
|
||||
}
|
||||
|
||||
export interface LoginTotals {
|
||||
logins: number;
|
||||
failures: number;
|
||||
devices: number;
|
||||
users: number;
|
||||
addresses: number;
|
||||
first: string;
|
||||
last: string;
|
||||
}
|
||||
|
||||
export interface LoginDay {
|
||||
day: string;
|
||||
logins: number;
|
||||
failures: number;
|
||||
devices: number;
|
||||
}
|
||||
|
||||
export interface LoginAddress {
|
||||
ipAddress: string;
|
||||
logins: number;
|
||||
failures: number;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface LoginDeviceSummary {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
embyUserId: string;
|
||||
username: string;
|
||||
clientVersion: string;
|
||||
logins: number;
|
||||
failures: number;
|
||||
firstLogin: string;
|
||||
lastLogin: string;
|
||||
lastIp: string;
|
||||
distinctIps: number;
|
||||
loginsToday: number;
|
||||
}
|
||||
|
||||
export interface LoginsResponse {
|
||||
events: LoginEvent[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
totals: LoginTotals;
|
||||
days: LoginDay[];
|
||||
addresses: LoginAddress[];
|
||||
users: KnownUser[];
|
||||
retentionDays: number;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export interface LoginDevicesResponse {
|
||||
devices: LoginDeviceSummary[];
|
||||
totals: LoginTotals;
|
||||
users: KnownUser[];
|
||||
timezone: string;
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
export interface DeviceDetailResponse {
|
||||
deviceId: string;
|
||||
summary?: LoginDeviceSummary;
|
||||
events: LoginEvent[];
|
||||
total: number;
|
||||
days: LoginDay[];
|
||||
addresses: LoginAddress[];
|
||||
versions: DeviceVersion[];
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
/* ---------- scheduled tasks ---------- */
|
||||
|
||||
export interface TaskRun {
|
||||
id: number;
|
||||
taskId: string;
|
||||
trigger: string;
|
||||
status: 'running' | 'success' | 'failed' | 'skipped';
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
durationMs: number;
|
||||
detail?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ScheduledTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
group: string;
|
||||
intervalSeconds: number;
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
nextRun?: string;
|
||||
lastRun?: TaskRun;
|
||||
}
|
||||
|
||||
export interface TasksResponse {
|
||||
tasks: ScheduledTask[];
|
||||
groups: string[];
|
||||
runs: TaskRun[];
|
||||
}
|
||||
|
||||
/* ---------- integrations ---------- */
|
||||
|
||||
export interface IntegrationHealth {
|
||||
integrationId: string;
|
||||
lastSuccess?: string;
|
||||
lastFailure?: string;
|
||||
lastError?: string;
|
||||
deliveries: number;
|
||||
failures: number;
|
||||
}
|
||||
|
||||
export interface IntegrationDelivery {
|
||||
id: number;
|
||||
integrationId: string;
|
||||
eventType: string;
|
||||
attemptedAt: string;
|
||||
success: boolean;
|
||||
statusCode: number;
|
||||
durationMs: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
events: string[] | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** hasUrl rather than the address itself: the webhook URL is the credential and the
|
||||
* gateway never returns it. `hint` is the channel id, which is enough to tell two
|
||||
* rows apart and carries none of the token. */
|
||||
hasUrl: boolean;
|
||||
hint?: string;
|
||||
health: IntegrationHealth;
|
||||
deliveries: IntegrationDelivery[];
|
||||
}
|
||||
|
||||
export interface IntegrationEventOption {
|
||||
type: string;
|
||||
label: string;
|
||||
description: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
export interface IntegrationsResponse {
|
||||
integrations: Integration[];
|
||||
catalogue: IntegrationEventOption[];
|
||||
dropped: number;
|
||||
}
|
||||
|
||||
/* ---------- logs ---------- */
|
||||
|
||||
export interface LogEvent {
|
||||
occurredAt: string;
|
||||
level: string;
|
||||
message: string;
|
||||
attributes: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface LogResponse {
|
||||
events: LogEvent[] | null;
|
||||
next: number;
|
||||
oldest: number;
|
||||
latest: number;
|
||||
dropped: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/* ---------- runtime ---------- */
|
||||
|
||||
export interface RuntimeStatus {
|
||||
goroutines: number;
|
||||
gomaxprocs: number;
|
||||
heapAlloc: number;
|
||||
heapInuse: number;
|
||||
heapIdle: number;
|
||||
heapReleased: number;
|
||||
stackInuse: number;
|
||||
sys: number;
|
||||
nextGc: number;
|
||||
numGc: number;
|
||||
memoryLimit: number;
|
||||
configuredLimit?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user