cbb703341e
Slice 4b frontend half. Jellyfin-touching pages now select a Jellyfin service instance instead of a machine. - api/client.ts: Jellyfin-backed calls (counts/libraries/activity/users, media status/build/stop/force-stop, queryMedia) send jellyfin_service_id. - hooks/useDashboard, useUsers, useMedia: selector param renamed to jellyfinServiceId. - pages/Media + Applications: list jellyfin service instances and persist jellyfin_service_id in the URL. - Dashboard (widgets) and Users (default instance) need no selector change. - Update Applications + Media tests for the new hook/param. Files/SSH transport keeps machine_id. Verification: frontend lint 0 errors, build success, 70 tests; backend ruff clean, 222 tests.
392 lines
11 KiB
TypeScript
392 lines
11 KiB
TypeScript
/**
|
|
* Typed API client for the FastAPI backend.
|
|
*/
|
|
|
|
import { getAccessToken } from "../auth";
|
|
import type {
|
|
MediaCounts,
|
|
LibraryCount,
|
|
UserDirectoryResponse,
|
|
UserMessageResponse,
|
|
UserMessageQueueStatus,
|
|
NowPlayingSession,
|
|
AppVersionInfo,
|
|
SSHKey,
|
|
SSHKeyInput,
|
|
SSHKeyGenerated,
|
|
SavedTask,
|
|
SavedTaskInput,
|
|
SavedTaskRun,
|
|
MonitoringMachine,
|
|
MonitoringMachineInput,
|
|
MediaIndexStatus,
|
|
MediaIndexActionResponse,
|
|
MediaQueryResponse,
|
|
DirectoryListing,
|
|
JobTemplate,
|
|
JobResult,
|
|
ResolvedPath,
|
|
ResetLocalDatabaseInput,
|
|
ResetLocalDatabaseResponse,
|
|
SSHValidationResult,
|
|
DashboardShortcut,
|
|
DashboardShortcutInput,
|
|
AlertmanagerAlertSummary,
|
|
AlertmanagerStatus,
|
|
PrometheusTarget,
|
|
} from "../types";
|
|
|
|
const BASE_URL = import.meta.env.VITE_API_URL || "/api";
|
|
|
|
function isAbsoluteUrl(value: string): boolean {
|
|
return /^https?:\/\//i.test(value) || value.startsWith("//");
|
|
}
|
|
|
|
function buildUrl(path: string, params?: Record<string, string>): string {
|
|
if (!isAbsoluteUrl(BASE_URL)) {
|
|
const url = new URL(path, window.location.origin);
|
|
if (params) {
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== "")
|
|
url.searchParams.set(key, value);
|
|
});
|
|
}
|
|
return url.toString();
|
|
}
|
|
|
|
const url = new URL(path, BASE_URL);
|
|
if (params) {
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== "") url.searchParams.set(key, value);
|
|
});
|
|
}
|
|
return url.toString();
|
|
}
|
|
|
|
async function readErrorDetail(response: Response): Promise<string> {
|
|
const text = await response.text();
|
|
try {
|
|
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown };
|
|
const detail = parsed.detail ?? parsed.message;
|
|
if (typeof detail === "string" && detail.trim()) {
|
|
return detail;
|
|
}
|
|
} catch {
|
|
// Fall back to the raw response body below.
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function buildHeaders(isJsonBody: boolean): Headers {
|
|
const headers = new Headers();
|
|
const token = getAccessToken();
|
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
if (isJsonBody) headers.set("Content-Type", "application/json");
|
|
return headers;
|
|
}
|
|
|
|
async function get<T>(
|
|
path: string,
|
|
params?: Record<string, string>,
|
|
): Promise<T> {
|
|
const response = await fetch(buildUrl(path, params), {
|
|
headers: buildHeaders(false),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function post<T>(path: string, body?: unknown): Promise<T> {
|
|
const response = await fetch(buildUrl(path), {
|
|
method: "POST",
|
|
headers: buildHeaders(true),
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function postForm<T>(path: string, body: FormData): Promise<T> {
|
|
const headers = buildHeaders(false);
|
|
const response = await fetch(buildUrl(path), {
|
|
method: "POST",
|
|
headers,
|
|
body,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function del<T>(path: string): Promise<T> {
|
|
const response = await fetch(buildUrl(path), {
|
|
method: "DELETE",
|
|
headers: buildHeaders(false),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
// Dashboard (Jellyfin-backed; selected via jellyfin_service_id)
|
|
export const fetchCounts = (jellyfinServiceId?: string) =>
|
|
get<MediaCounts>(
|
|
"/api/dashboard/counts",
|
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
|
);
|
|
export const fetchLibraries = (jellyfinServiceId?: string) =>
|
|
get<LibraryCount[]>(
|
|
"/api/dashboard/libraries",
|
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
|
);
|
|
export const fetchActivity = (jellyfinServiceId?: string) =>
|
|
get<NowPlayingSession[]>(
|
|
"/api/dashboard/activity",
|
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
|
);
|
|
export const fetchUsers = (jellyfinServiceId?: string) =>
|
|
get<UserDirectoryResponse>(
|
|
"/api/users",
|
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
|
);
|
|
|
|
// Backward-compatible alias used by older hooks/components.
|
|
export const fetchNowPlaying = fetchActivity;
|
|
|
|
// Monitoring
|
|
export const fetchMonitoringMachines = () =>
|
|
get<MonitoringMachine[]>("/api/monitoring/machines");
|
|
export const fetchAppVersion = () => get<AppVersionInfo>("/api/version");
|
|
export const fetchDashboardShortcuts = () =>
|
|
get<DashboardShortcut[]>("/api/dashboard/shortcuts");
|
|
export const saveDashboardShortcut = (shortcut: DashboardShortcutInput) =>
|
|
fetch(
|
|
buildUrl(
|
|
shortcut.id
|
|
? `/api/dashboard/shortcuts/${encodeURIComponent(shortcut.id)}`
|
|
: "/api/dashboard/shortcuts",
|
|
),
|
|
{
|
|
method: shortcut.id ? "PUT" : "POST",
|
|
headers: buildHeaders(true),
|
|
body: JSON.stringify(shortcut),
|
|
},
|
|
).then(async (response) => {
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
|
}
|
|
return response.json() as Promise<DashboardShortcut>;
|
|
});
|
|
export const deleteDashboardShortcut = (shortcutId: string) =>
|
|
del<{ status: string }>(
|
|
`/api/dashboard/shortcuts/${encodeURIComponent(shortcutId)}`,
|
|
);
|
|
export const fetchMonitoringSettings = () =>
|
|
get<MonitoringMachine[]>("/api/settings/machines");
|
|
export const fetchSSHKeys = () => get<SSHKey[]>("/api/settings/ssh-keys");
|
|
export const generateSSHKey = (payload: {
|
|
name: string;
|
|
passphrase: string;
|
|
notes: string;
|
|
bits?: number;
|
|
}) => post<SSHKeyGenerated>("/api/settings/ssh-keys/generate", payload);
|
|
export const saveSSHKey = (key: SSHKeyInput) =>
|
|
fetch(
|
|
buildUrl(
|
|
key.id
|
|
? `/api/settings/ssh-keys/${encodeURIComponent(key.id)}`
|
|
: "/api/settings/ssh-keys",
|
|
),
|
|
{
|
|
method: key.id ? "PUT" : "POST",
|
|
headers: buildHeaders(true),
|
|
body: JSON.stringify(key),
|
|
},
|
|
).then(async (response) => {
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
|
}
|
|
return response.json() as Promise<SSHKey>;
|
|
});
|
|
export const deleteSSHKey = (keyId: string) =>
|
|
del<{ status: string }>(
|
|
`/api/settings/ssh-keys/${encodeURIComponent(keyId)}`,
|
|
);
|
|
|
|
export const fetchSavedTasks = () => get<SavedTask[]>("/api/tasks");
|
|
export const fetchSavedTaskRuns = (taskId: string, limit = 10) =>
|
|
get<{ items: SavedTaskRun[]; total: number }>(
|
|
`/api/tasks/${encodeURIComponent(taskId)}/runs`,
|
|
{
|
|
limit: String(limit),
|
|
},
|
|
);
|
|
export const saveTask = (task: SavedTaskInput) =>
|
|
fetch(
|
|
buildUrl(
|
|
task.id ? `/api/tasks/${encodeURIComponent(task.id)}` : "/api/tasks",
|
|
),
|
|
{
|
|
method: task.id ? "PUT" : "POST",
|
|
headers: buildHeaders(true),
|
|
body: JSON.stringify(task),
|
|
},
|
|
).then(async (response) => {
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
|
}
|
|
return response.json() as Promise<SavedTask>;
|
|
});
|
|
export const deleteTask = (taskId: string) =>
|
|
del<{ status: string }>(`/api/tasks/${encodeURIComponent(taskId)}`);
|
|
export const runTask = (taskId: string, machineId?: string) =>
|
|
post<{
|
|
task_id: string;
|
|
task_name: string;
|
|
machine_id: string;
|
|
machine_name: string;
|
|
task_type: string;
|
|
exit_status: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}>(
|
|
machineId
|
|
? `/api/tasks/run?machine_id=${encodeURIComponent(machineId)}`
|
|
: "/api/tasks/run",
|
|
{ task_id: taskId },
|
|
);
|
|
|
|
export const saveMonitoringMachine = (machine: MonitoringMachineInput) =>
|
|
fetch(
|
|
buildUrl(
|
|
machine.id
|
|
? `/api/settings/machines/${encodeURIComponent(machine.id)}`
|
|
: "/api/settings/machines",
|
|
),
|
|
{
|
|
method: machine.id ? "PUT" : "POST",
|
|
headers: buildHeaders(true),
|
|
body: JSON.stringify(machine),
|
|
},
|
|
).then(async (response) => {
|
|
if (!response.ok) {
|
|
throw new Error(`${response.status}: ${await readErrorDetail(response)}`);
|
|
}
|
|
return response.json() as Promise<MonitoringMachine>;
|
|
});
|
|
export const testMonitoringMachineSSH = (machine: MonitoringMachineInput) =>
|
|
post<SSHValidationResult>("/api/settings/machines/test-ssh", machine);
|
|
export const deleteMonitoringMachine = (machineId: string) =>
|
|
del<{ status: string }>(
|
|
`/api/settings/machines/${encodeURIComponent(machineId)}`,
|
|
);
|
|
export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
|
|
post<ResetLocalDatabaseResponse>(
|
|
"/api/settings/reset-local-database",
|
|
payload,
|
|
);
|
|
|
|
// Media
|
|
export const fetchMediaStatus = (jellyfinServiceId?: string) =>
|
|
get<MediaIndexStatus>(
|
|
"/api/media/status",
|
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
|
);
|
|
export const buildMediaIndex = (jellyfinServiceId?: string) =>
|
|
post<MediaIndexActionResponse>(
|
|
jellyfinServiceId
|
|
? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
|
: "/api/media/build",
|
|
);
|
|
export const stopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
|
post<MediaIndexActionResponse>(
|
|
jellyfinServiceId
|
|
? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
|
: "/api/media/stop",
|
|
);
|
|
export const forceStopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
|
post<MediaIndexActionResponse>(
|
|
jellyfinServiceId
|
|
? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
|
: "/api/media/force-stop",
|
|
);
|
|
export const queryMedia = (params: {
|
|
libraries?: string;
|
|
types?: string;
|
|
search?: string;
|
|
hdr_filter?: string;
|
|
sort_key?: string;
|
|
sort_order?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
jellyfinServiceId?: string;
|
|
}) =>
|
|
get<MediaQueryResponse>("/api/media/query", {
|
|
libraries: params.libraries || "",
|
|
types: params.types || "Movie,Episode",
|
|
search: params.search || "",
|
|
hdr_filter: params.hdr_filter || "All",
|
|
sort_key: params.sort_key || "title",
|
|
sort_order: params.sort_order || "Ascending",
|
|
limit: String(params.limit || 100),
|
|
offset: String(params.offset || 0),
|
|
...(params.jellyfinServiceId
|
|
? { jellyfin_service_id: params.jellyfinServiceId }
|
|
: {}),
|
|
});
|
|
|
|
// Files
|
|
export const fetchDirectoryListing = (path: string, machineId?: string) =>
|
|
get<DirectoryListing>("/api/files/list", {
|
|
path,
|
|
...(machineId ? { machine_id: machineId } : {}),
|
|
});
|
|
export const fetchFfprobe = (path: string, machineId?: string) =>
|
|
get<Record<string, unknown>>("/api/files/ffprobe", {
|
|
path,
|
|
...(machineId ? { machine_id: machineId } : {}),
|
|
});
|
|
export const fetchStat = (path: string, machineId?: string) =>
|
|
get<{ path: string; output: string }>("/api/files/stat", {
|
|
path,
|
|
...(machineId ? { machine_id: machineId } : {}),
|
|
});
|
|
export const resolvePath = (path: string, machineId?: string) =>
|
|
get<ResolvedPath>("/api/files/resolve-path", {
|
|
path,
|
|
...(machineId ? { machine_id: machineId } : {}),
|
|
});
|
|
|
|
// Jobs
|
|
export const fetchJobTemplates = () =>
|
|
get<JobTemplate[]>("/api/jobs/templates");
|
|
export const runJob = (jobKey: string, path: string, machineId?: string) =>
|
|
post<JobResult>(
|
|
machineId
|
|
? `/api/jobs/run?machine_id=${encodeURIComponent(machineId)}`
|
|
: "/api/jobs/run",
|
|
{ job_key: jobKey, path },
|
|
);
|
|
|
|
export const fetchUserMessageQueueStatus = () =>
|
|
get<UserMessageQueueStatus>("/api/users/message/status");
|
|
|
|
export const sendUserMessage = (formData: FormData) =>
|
|
postForm<UserMessageResponse>("/api/users/message", formData);
|
|
|
|
// Observability summary endpoints
|
|
export const fetchAlertmanagerAlerts = () =>
|
|
get<AlertmanagerAlertSummary>("/api/monitoring/alerts");
|
|
|
|
export const fetchAlertmanagerStatus = () =>
|
|
get<AlertmanagerStatus>("/api/monitoring/alertmanager-status");
|
|
|
|
export const fetchPrometheusTargets = () =>
|
|
get<PrometheusTarget[]>("/api/monitoring/prometheus-targets");
|