0.1.38 gateway

This commit is contained in:
ponzischeme89
2026-08-14 09:40:03 +12:00
parent abc392d30b
commit 5e2ed3d12e
2847 changed files with 1072928 additions and 3783 deletions
+97
View File
@@ -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' }),
};
+413
View File
@@ -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;
}