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
+115
View File
@@ -0,0 +1,115 @@
import { useCounts, useLibraries, useNowPlaying } from "../hooks/useDashboard";
import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring";
import { NowPlaying } from "../components/NowPlaying";
import { MetricCard } from "../components/MetricCard";
import { LibraryOverview } from "../components/LibraryOverview";
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatRate(bytes: number): string {
return `${formatBytes(bytes)}/s`;
}
export function Dashboard() {
const { data: counts } = useCounts();
const { data: libraries } = useLibraries();
const { data: nowPlaying } = useNowPlaying();
const { data: metrics } = useMonitoringMetrics();
const { data: disk } = useDiskSpace();
const latest = metrics?.samples?.at(-1);
return (
<div className="space-y-8">
{/* Now Playing */}
<section>
<h2 className="text-lg font-semibold mb-3">Now playing</h2>
{nowPlaying && <NowPlaying sessions={nowPlaying} />}
</section>
<hr />
{/* Server Overview */}
<section>
<h2 className="text-lg font-semibold mb-3">Server overview</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
<MetricCard
label="CPU"
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
/>
<MetricCard
label="IO Wait"
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
/>
<MetricCard
label="RAM"
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
/>
<MetricCard
label="Net down"
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
/>
<MetricCard
label="Net up"
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
/>
<MetricCard
label="Disk read"
value={latest ? formatRate(latest.disk_read_bps) : "-"}
/>
<MetricCard
label="Disk write"
value={latest ? formatRate(latest.disk_write_bps) : "-"}
/>
</div>
{disk && (
<div className="mt-3 grid grid-cols-4 gap-3">
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
<MetricCard label="Used %" value={disk.used_pct} />
</div>
)}
</section>
<hr />
{/* Media Library Overview */}
<section>
<h2 className="text-lg font-semibold mb-3">Media library overview</h2>
{counts && (
<div className="grid grid-cols-4 gap-3 mb-4">
<MetricCard
label="Total"
value={(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
/>
<MetricCard label="Movies" value={counts.movies.toLocaleString()} />
<MetricCard label="Series" value={counts.series.toLocaleString()} />
<MetricCard
label="Episodes"
value={counts.episodes.toLocaleString()}
/>
</div>
)}
{libraries && <LibraryOverview libraries={libraries} />}
</section>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { useState, useCallback, useRef } from "react";
import { AgGridReact } from "ag-grid-react";
import {
useDirectoryListing,
useFfprobe,
useJobTemplates,
useRunJob,
} from "../hooks/useFiles";
interface DisplayRow {
type: string;
name: string;
ext: string;
size: string;
modified: string;
path: string;
}
function formatSize(bytes: number): string {
if (bytes === 0) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatTime(epoch: number): string {
if (!epoch) return "";
return new Date(epoch * 1000).toLocaleString();
}
function isVideoFile(name: string): boolean {
const exts = [
".mkv",
".mp4",
".avi",
".m4v",
".ts",
".wmv",
".mov",
".flv",
".webm",
];
return exts.some((ext) => name.toLowerCase().endsWith(ext));
}
export function FileBrowser() {
const [currentDir, setCurrentDir] = useState("/");
const [pathInput, setPathInput] = useState("/");
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const { data: listing, isLoading, error } = useDirectoryListing(currentDir);
const { data: ffprobeData } = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
);
const { data: templates } = useJobTemplates();
const runJob = useRunJob();
const gridRef = useRef<AgGridReact<DisplayRow>>(null);
const navigate = useCallback((path: string) => {
setCurrentDir(path);
setPathInput(path);
setSelectedPath(null);
}, []);
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
navigate(pathInput || "/");
}
};
// Build display rows
const rows: DisplayRow[] = [];
if (currentDir !== "/") {
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
rows.push({
type: "up",
name: "..",
ext: "",
size: "-",
modified: "",
path: parent,
});
}
if (listing) {
for (const entry of listing.entries) {
const kind = entry.type === "d" ? "dir" : "file";
const ext = kind === "file" ? (entry.name.split(".").pop() ?? "") : "";
rows.push({
type: kind,
name: entry.name,
ext,
size: kind === "dir" ? "-" : formatSize(entry.size),
modified: formatTime(entry.mtime),
path: `${currentDir === "/" ? "" : currentDir}/${entry.name}`,
});
}
}
const columnDefs = [
{ field: "type" as const, headerName: "Type", width: 80 },
{ field: "name" as const, headerName: "Name", flex: 2 },
{ field: "ext" as const, headerName: "Ext", width: 80 },
{ field: "size" as const, headerName: "Size", width: 110 },
{ field: "modified" as const, headerName: "Modified", width: 180 },
];
const onRowClicked = useCallback(
(event: { data?: DisplayRow }) => {
const row = event.data;
if (!row) return;
if (row.type === "dir" || row.type === "up") {
navigate(row.path);
} else {
setSelectedPath(row.path);
}
},
[navigate],
);
return (
<div className="space-y-4">
{/* Path input */}
<div className="flex gap-2">
<input
type="text"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathSubmit}
className="border rounded px-3 py-1 text-sm flex-1"
placeholder="Remote path (press Enter to navigate)"
/>
<button
onClick={() => navigate(pathInput || "/")}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
Refresh
</button>
</div>
{/* Status */}
<div className="flex gap-6 text-xs text-gray-500">
<span>
Current: <code>{currentDir}</code>
</span>
{selectedPath && (
<span>
Selected: <code>{selectedPath}</code>
</span>
)}
{listing && <span>Entries: {listing.count}</span>}
</div>
{error && <p className="text-sm text-red-600">Error: {String(error)}</p>}
{/* File listing grid */}
<div className="ag-theme-alpine" style={{ height: 400, width: "100%" }}>
<AgGridReact<DisplayRow>
ref={gridRef}
rowData={rows}
columnDefs={columnDefs}
rowSelection="single"
onRowClicked={onRowClicked}
loading={isLoading}
suppressCellFocus
animateRows={false}
/>
</div>
{/* ffprobe preview */}
{selectedPath && isVideoFile(selectedPath) && (
<section className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-2">
ffprobe preview: <code className="text-xs">{selectedPath}</code>
</h3>
{ffprobeData ? (
<pre className="text-xs bg-gray-50 p-3 rounded overflow-auto max-h-96">
{JSON.stringify(ffprobeData, null, 2)}
</pre>
) : (
<p className="text-sm text-gray-500">Loading ffprobe data...</p>
)}
</section>
)}
{/* Jobs */}
{selectedPath && templates && templates.length > 0 && (
<section className="border rounded-lg p-4">
<h3 className="text-sm font-semibold mb-2">Jobs</h3>
<div className="flex gap-2 flex-wrap">
{templates.map((tpl) => (
<button
key={tpl.key}
onClick={() =>
runJob.mutate({ jobKey: tpl.key, path: selectedPath })
}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
title={tpl.description}
>
{tpl.name}
</button>
))}
</div>
{runJob.data && (
<pre className="text-xs bg-gray-50 p-3 rounded mt-3 overflow-auto max-h-48">
Exit: {runJob.data.exit_status}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</pre>
)}
</section>
)}
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
import { useState, useCallback, useRef } from "react";
import { AgGridReact } from "ag-grid-react";
import {
useMediaStatus,
useMediaQuery,
useBuildIndex,
} from "../hooks/useMedia";
import type { MediaItem } from "../types";
export function Media() {
const { data: status } = useMediaStatus();
const buildIndex = useBuildIndex();
const [search, setSearch] = useState("");
const [types, setTypes] = useState("Movie,Episode");
const [hdrFilter, setHdrFilter] = useState("All");
const [sortKey, setSortKey] = useState("title");
const [sortOrder, setSortOrder] = useState("Ascending");
const [limit] = useState(100);
const [offset, setOffset] = useState(0);
const { data: queryResult, isLoading } = useMediaQuery({
types,
search,
hdr_filter: hdrFilter,
sort_key: sortKey,
sort_order: sortOrder,
limit,
offset,
enabled: status?.exists ?? false,
});
const gridRef = useRef<AgGridReact<MediaItem>>(null);
const columnDefs = [
{ field: "title" as const, headerName: "Title", minWidth: 150 },
{ field: "series" as const, headerName: "Series", minWidth: 120 },
{ field: "season" as const, headerName: "Season", maxWidth: 95 },
{ field: "episode" as const, headerName: "Episode", maxWidth: 105 },
{ field: "type" as const, headerName: "Type", maxWidth: 100 },
{ field: "year" as const, headerName: "Year", maxWidth: 90 },
{
field: "runtime_min" as const,
headerName: "Runtime (min)",
maxWidth: 125,
},
{ field: "size" as const, headerName: "Size", maxWidth: 120 },
{ field: "bitrate" as const, headerName: "Bitrate", maxWidth: 125 },
{ field: "hdr" as const, headerName: "HDR", maxWidth: 80 },
{ field: "video" as const, headerName: "Video codec", maxWidth: 120 },
{ field: "resolution" as const, headerName: "Resolution", maxWidth: 120 },
{ field: "date_added" as const, headerName: "Date added", maxWidth: 120 },
{ field: "library" as const, headerName: "Library", maxWidth: 140 },
{ field: "path" as const, headerName: "Path", minWidth: 200 },
];
const onGridReady = useCallback(() => {
gridRef.current?.api?.sizeColumnsToFit();
}, []);
const page = Math.floor(offset / limit) + 1;
const totalPages = queryResult ? Math.ceil(queryResult.total / limit) : 1;
return (
<div className="space-y-4">
{/* Status and controls */}
<div className="flex items-center gap-4">
{status?.exists ? (
<span className="text-sm text-gray-600">
Index: {status.item_count.toLocaleString()} items
{status.updated_at_label && ` | updated ${status.updated_at_label}`}
</span>
) : (
<span className="text-sm text-amber-600">No index built yet.</span>
)}
<button
onClick={() => buildIndex.mutate()}
disabled={buildIndex.isPending}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50 disabled:opacity-50"
>
{buildIndex.isPending ? "Building..." : "Build index"}
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-end">
<div>
<label className="text-xs text-gray-500 block">Search</label>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm w-48"
placeholder="Search title, series, path..."
/>
</div>
<div>
<label className="text-xs text-gray-500 block">Types</label>
<select
value={types}
onChange={(e) => {
setTypes(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm"
>
<option value="Movie,Episode">Movies + Episodes</option>
<option value="Movie">Movies only</option>
<option value="Episode">Episodes only</option>
<option value="Movie,Episode,Video">All video</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">HDR</label>
<select
value={hdrFilter}
onChange={(e) => {
setHdrFilter(e.target.value);
setOffset(0);
}}
className="border rounded px-2 py-1 text-sm"
>
<option value="All">All</option>
<option value="HDR only">HDR only</option>
<option value="SDR/unknown only">SDR/unknown only</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">Sort</label>
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value)}
className="border rounded px-2 py-1 text-sm"
>
<option value="title">Title</option>
<option value="series">Series</option>
<option value="size">Size</option>
<option value="bitrate">Bitrate</option>
<option value="runtime">Runtime</option>
<option value="year">Year</option>
<option value="date_added">Date added</option>
<option value="resolution">Resolution</option>
</select>
</div>
<div>
<label className="text-xs text-gray-500 block">Order</label>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
className="border rounded px-2 py-1 text-sm"
>
<option value="Ascending">Ascending</option>
<option value="Descending">Descending</option>
</select>
</div>
</div>
{/* Results info */}
{queryResult && (
<p className="text-xs text-gray-500">
Showing {queryResult.items.length} of{" "}
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
{totalPages}
</p>
)}
{/* AG Grid table */}
{status?.exists && (
<div className="ag-theme-alpine" style={{ height: 600, width: "100%" }}>
<AgGridReact<MediaItem>
ref={gridRef}
rowData={queryResult?.items ?? []}
columnDefs={columnDefs}
rowSelection="single"
onGridReady={onGridReady}
loading={isLoading}
suppressCellFocus
animateRows={false}
/>
</div>
)}
{/* Pagination */}
{queryResult && totalPages > 1 && (
<div className="flex gap-2 items-center">
<button
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={page <= 1}
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
>
Prev
</button>
<span className="text-sm">
Page {page} / {totalPages}
</span>
<button
onClick={() => setOffset(offset + limit)}
disabled={page >= totalPages}
className="px-3 py-1 text-sm rounded border disabled:opacity-50"
>
Next
</button>
</div>
)}
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import {
useMonitoringStatus,
useMonitoringMetrics,
useDiskSpace,
useCollectorControls,
} from "../hooks/useMonitoring";
import { MetricCard } from "../components/MetricCard";
import { MonitoringCharts } from "../components/MonitoringCharts";
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
function formatRate(bytes: number): string {
return `${formatBytes(bytes)}/s`;
}
export function Monitoring() {
const { data: status } = useMonitoringStatus();
const { data: metrics } = useMonitoringMetrics();
const { data: disk } = useDiskSpace();
const { start, stop, restart } = useCollectorControls();
const samples = metrics?.samples ?? [];
const latest = samples.at(-1);
// Compute averages and peaks
const avg = (arr: number[]) =>
arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
const max = (arr: number[]) => (arr.length ? Math.max(...arr) : 0);
const cpuArr = samples.map((s) => s.cpu_pct);
const iowArr = samples.map((s) => s.iowait_pct ?? 0);
const memArr = samples.map((s) => s.mem_pct);
const netDownArr = samples.map((s) => s.net_rx_bytes_per_sec);
const netUpArr = samples.map((s) => s.net_tx_bytes_per_sec);
const diskReadArr = samples.map((s) => s.disk_read_bps);
const diskWriteArr = samples.map((s) => s.disk_write_bps);
return (
<div className="space-y-8">
{/* Controls */}
<section className="flex items-center gap-4">
<span className="text-sm text-gray-600">
Collector:{" "}
<code className="bg-gray-100 px-1 rounded">
{status?.status ?? "unknown"}
</code>
</span>
<button
onClick={() => start.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
Start
</button>
<button
onClick={() => restart.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
Restart
</button>
<button
onClick={() => stop.mutate()}
className="px-3 py-1 text-sm rounded border hover:bg-gray-50"
>
Stop
</button>
</section>
{/* Metrics summary */}
<section>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
<MetricCard
label="CPU now"
value={latest ? `${latest.cpu_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(cpuArr).toFixed(1)}%\npeak ${max(cpuArr).toFixed(1)}%`}
/>
<MetricCard
label="IO Wait"
value={latest ? `${(latest.iowait_pct ?? 0).toFixed(1)}%` : "-"}
subtext={`avg ${avg(iowArr).toFixed(1)}%\npeak ${max(iowArr).toFixed(1)}%`}
/>
<MetricCard
label="RAM now"
value={latest ? `${latest.mem_pct.toFixed(1)}%` : "-"}
subtext={`avg ${avg(memArr).toFixed(1)}%\npeak ${max(memArr).toFixed(1)}%`}
/>
<MetricCard
label="Net down"
value={latest ? formatRate(latest.net_rx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netDownArr))}\npeak ${formatRate(max(netDownArr))}`}
/>
<MetricCard
label="Net up"
value={latest ? formatRate(latest.net_tx_bytes_per_sec) : "-"}
subtext={`avg ${formatRate(avg(netUpArr))}\npeak ${formatRate(max(netUpArr))}`}
/>
<MetricCard
label="Disk read"
value={latest ? formatRate(latest.disk_read_bps) : "-"}
subtext={`avg ${formatRate(avg(diskReadArr))}\npeak ${formatRate(max(diskReadArr))}`}
/>
<MetricCard
label="Disk write"
value={latest ? formatRate(latest.disk_write_bps) : "-"}
subtext={`avg ${formatRate(avg(diskWriteArr))}\npeak ${formatRate(max(diskWriteArr))}`}
/>
</div>
</section>
{/* Disk space */}
{disk && (
<section>
<div className="grid grid-cols-4 gap-3">
<MetricCard label="Disk used" value={formatBytes(disk.used)} />
<MetricCard
label="Disk available"
value={formatBytes(disk.available)}
/>
<MetricCard label="Disk total" value={formatBytes(disk.size)} />
<MetricCard label="Used %" value={disk.used_pct} />
</div>
</section>
)}
{/* Charts */}
<section>
<MonitoringCharts samples={samples} />
</section>
</div>
);
}