Add FastAPI backend and React frontend subprojects

Backend:
- FastAPI app with 17 REST endpoints covering dashboard, monitoring,
  media index, file browser, and jobs
- Reuses existing clients/domain/services unchanged
- pydantic-settings config, dependency injection, CORS setup
- Auto-generated OpenAPI docs at /docs

Frontend:
- Vite + React + TypeScript SPA
- @tanstack/react-query for data fetching with polling
- ag-grid-react for media table and file browser
- recharts for monitoring charts
- Tailwind CSS styling
- 4 pages: Dashboard, Monitoring, Media, File Browser
- Typed API client matching all backend endpoints

Also:
- docs/MIGRATION_PLAN.md with full architecture plan
- Updated .gitignore for both subprojects
- Streamlit app preserved for now (can coexist)
This commit is contained in:
2026-04-30 21:40:18 +02:00
parent 1acdfbc6ba
commit 3c432473e5
63 changed files with 7778 additions and 202 deletions
+116
View File
@@ -0,0 +1,116 @@
/**
* Typed API client for the FastAPI backend.
*/
import type {
MediaCounts,
LibraryCount,
NowPlayingSession,
MonitoringStatus,
MonitoringMetrics,
DiskSpace,
MediaIndexStatus,
MediaQueryResponse,
DirectoryListing,
JobTemplate,
JobResult,
ResolvedPath,
} from "../types";
const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
async function get<T>(
path: string,
params?: Record<string, string>,
): Promise<T> {
const url = new URL(path, BASE_URL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== "") url.searchParams.set(key, value);
});
}
const response = await fetch(url.toString());
if (!response.ok) {
const detail = await response.text();
throw new Error(`${response.status}: ${detail}`);
}
return response.json();
}
async function post<T>(path: string, body?: unknown): Promise<T> {
const url = new URL(path, BASE_URL);
const response = await fetch(url.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`${response.status}: ${detail}`);
}
return response.json();
}
// Dashboard
export const fetchCounts = () => get<MediaCounts>("/api/dashboard/counts");
export const fetchLibraries = () =>
get<LibraryCount[]>("/api/dashboard/libraries");
export const fetchNowPlaying = () =>
get<NowPlayingSession[]>("/api/dashboard/now-playing");
// Monitoring
export const fetchMonitoringStatus = () =>
get<MonitoringStatus>("/api/monitoring/status");
export const fetchMonitoringMetrics = (lastSeconds = 3600) =>
get<MonitoringMetrics>("/api/monitoring/metrics", {
last_seconds: String(lastSeconds),
});
export const fetchDiskSpace = () => get<DiskSpace>("/api/monitoring/disk");
export const startCollector = () =>
post<{ message: string }>("/api/monitoring/start");
export const stopCollector = () =>
post<{ message: string }>("/api/monitoring/stop");
export const restartCollector = () =>
post<{ message: string }>("/api/monitoring/restart");
// Media
export const fetchMediaStatus = () =>
get<MediaIndexStatus>("/api/media/status");
export const buildMediaIndex = () =>
post<{ indexed_items: number }>("/api/media/build");
export const queryMedia = (params: {
libraries?: string;
types?: string;
search?: string;
hdr_filter?: string;
sort_key?: string;
sort_order?: string;
limit?: number;
offset?: number;
}) =>
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),
});
// Files
export const fetchDirectoryListing = (path: string) =>
get<DirectoryListing>("/api/files/list", { path });
export const fetchFfprobe = (path: string) =>
get<Record<string, unknown>>("/api/files/ffprobe", { path });
export const fetchStat = (path: string) =>
get<{ path: string; output: string }>("/api/files/stat", { path });
export const resolvePath = (path: string) =>
get<ResolvedPath>("/api/files/resolve-path", { path });
// Jobs
export const fetchJobTemplates = () =>
get<JobTemplate[]>("/api/jobs/templates");
export const runJob = (jobKey: string, path: string) =>
post<JobResult>("/api/jobs/run", { job_key: jobKey, path });