refactor frontend and backend modules

This commit is contained in:
2026-05-04 22:57:57 +02:00
parent e0e461502b
commit 8f0a9650b0
17 changed files with 4560 additions and 4416 deletions
+2 -139
View File
@@ -1,19 +1,11 @@
import { useMemo } from "react";
import {
Box,
Card,
CardContent,
Divider,
Grid,
LinearProgress,
Stack,
Typography,
} from "@mui/material";
import { Box, Divider, Grid, Stack, Typography } from "@mui/material";
import { useNavigate } from "react-router-dom";
import { useCounts, useLibraries, useActivity } from "../hooks/useDashboard";
import { useMonitoringMetrics, useDiskSpace } from "../hooks/useMonitoring";
import { NowPlaying } from "../components/NowPlaying";
import { MetricCard } from "../components/MetricCard";
import { DiskSpaceCard } from "../components/DiskSpaceCard";
import { LibraryOverview } from "../components/LibraryOverview";
function formatBytes(bytes: number): string {
@@ -46,135 +38,6 @@ function summarize(values: number[]) {
};
}
function DiskSpaceCard({
used,
available,
size,
usedPct,
}: {
used: number;
available: number;
size: number;
usedPct: string;
}) {
const pct = Math.max(0, Math.min(100, Number.parseFloat(usedPct) || 0));
const barColor = pct < 70 ? "success" : pct < 90 ? "warning" : "error";
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent sx={{ p: { xs: 1.5, sm: 2 } }}>
<Stack spacing={1.5}>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: "uppercase" }}
>
Disk space
</Typography>
<Typography
variant="h5"
sx={{
fontWeight: 700,
fontSize: { xs: "1.05rem", sm: "1.5rem" },
}}
>
{usedPct} used
</Typography>
</Box>
<Box sx={{ width: "100%" }}>
<LinearProgress
variant="determinate"
value={pct}
color={barColor}
sx={{
height: 12,
borderRadius: 999,
bgcolor: "action.hover",
"& .MuiLinearProgress-bar": {
borderRadius: 999,
},
}}
/>
</Box>
<Grid container spacing={1.5} sx={{ alignItems: "stretch" }}>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Used
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(used)}
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Free
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(available)}
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Total
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(size)}
</Typography>
</Box>
</Grid>
</Grid>
</Stack>
</CardContent>
</Card>
);
}
export function Dashboard() {
const navigate = useNavigate();
const { data: counts } = useCounts();
+829
View File
@@ -0,0 +1,829 @@
import { useSearchParams } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
useMediaQuery,
} from "@mui/material";
import {
useDirectoryListing,
useFfprobe,
useJobTemplates,
useRunJob,
} from "../hooks/useFiles";
import { usePersistentState } from "../hooks/usePersistentState";
interface DisplayRow {
id: string;
type: string;
name: string;
ext: string;
size: string;
modified: string;
path: string;
}
interface FfprobeStream {
index?: number;
codec_type?: string;
codec_name?: string;
codec_long_name?: string;
profile?: string;
width?: number;
height?: number;
bit_rate?: string | number;
duration?: string | number;
channels?: number;
sample_rate?: string | number;
channel_layout?: string;
pix_fmt?: string;
sample_aspect_ratio?: string;
display_aspect_ratio?: string;
field_order?: string;
level?: number | string;
color_range?: string;
color_space?: string;
color_transfer?: string;
color_primaries?: string;
tags?: Record<string, string>;
}
interface FfprobeFormat {
filename?: string;
format_name?: string;
format_long_name?: string;
duration?: string | number;
size?: string | number;
bit_rate?: string | number;
tags?: Record<string, string>;
}
interface FfprobeData {
format?: FfprobeFormat;
streams?: FfprobeStream[];
}
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 humanBytes(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const bytes = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(bytes)) return "-";
return formatSize(bytes);
}
function humanRate(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const rate = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(rate)) return "-";
const units = ["bps", "Kbps", "Mbps", "Gbps"];
let v = rate;
let unitIdx = 0;
while (v >= 1000 && unitIdx < units.length - 1) {
v /= 1000;
unitIdx++;
}
return `${v.toFixed(1)} ${units[unitIdx]}`;
}
function humanDuration(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const seconds = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(seconds)) return "-";
const total = Math.max(0, Math.round(seconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0)
return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
return `${minutes}:${String(secs).padStart(2, "0")}`;
}
function fieldLabel(_key: string, value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
return String(value);
}
function isVideoFile(name: string): boolean {
const exts = [
".mkv",
".mp4",
".avi",
".m4v",
".ts",
".wmv",
".mov",
".flv",
".webm",
];
return exts.some((ext) => name.toLowerCase().endsWith(ext));
}
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
type FileBrowserState = {
currentDir: string;
pathInput: string;
selectedPath: string | null;
selectedJob: string;
};
function defaultFileBrowserState(): FileBrowserState {
return {
currentDir: "/",
pathInput: "/",
selectedPath: null,
selectedJob: "",
};
}
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
const format = data.format ?? {};
const streams = data.streams ?? [];
const videoStreams = streams.filter(
(stream) => stream.codec_type === "video",
);
const audioStreams = streams.filter(
(stream) => stream.codec_type === "audio",
);
const subtitleStreams = streams.filter(
(stream) => stream.codec_type === "subtitle",
);
return (
<Stack spacing={2}>
<Box>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>
ffprobe details
</Typography>
<Typography variant="caption" color="text.secondary">
{path}
</Typography>
</Box>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Container / format
</Typography>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body2">
<b>Format:</b> {fieldLabel("format", format.format_name)}
</Typography>
<Typography variant="body2">
<b>Long name:</b>{" "}
{fieldLabel("format_long_name", format.format_long_name)}
</Typography>
<Typography variant="body2">
<b>Duration:</b> {humanDuration(format.duration)}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body2">
<b>Size:</b> {humanBytes(format.size)}
</Typography>
<Typography variant="body2">
<b>Bitrate:</b> {humanRate(format.bit_rate)}
</Typography>
<Typography variant="body2">
<b>Filename:</b> {fieldLabel("filename", format.filename)}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Streams
</Typography>
<Stack spacing={1.5}>
{videoStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Video streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{videoStreams.map((stream, index) => (
<Box
key={`video-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="primary"
label={stream.codec_type ?? "video"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.codec_long_name && (
<Chip
size="small"
variant="outlined"
label={stream.codec_long_name}
/>
)}
{stream.profile && (
<Chip
size="small"
variant="outlined"
label={stream.profile}
/>
)}
{stream.bit_rate && (
<Chip
size="small"
variant="outlined"
label={humanRate(stream.bit_rate)}
/>
)}
{stream.duration && (
<Chip
size="small"
variant="outlined"
label={humanDuration(stream.duration)}
/>
)}
{stream.width && stream.height && (
<Chip
size="small"
variant="outlined"
label={`${stream.width}×${stream.height}`}
/>
)}
{stream.pix_fmt && (
<Chip
size="small"
variant="outlined"
label={stream.pix_fmt}
/>
)}
{stream.display_aspect_ratio && (
<Chip
size="small"
variant="outlined"
label={`DAR ${stream.display_aspect_ratio}`}
/>
)}
{stream.sample_aspect_ratio && (
<Chip
size="small"
variant="outlined"
label={`SAR ${stream.sample_aspect_ratio}`}
/>
)}
{stream.level !== undefined &&
stream.level !== null && (
<Chip
size="small"
variant="outlined"
label={`L${stream.level}`}
/>
)}
{stream.field_order &&
stream.field_order !== "unknown" && (
<Chip
size="small"
variant="outlined"
label={stream.field_order}
/>
)}
{(stream.color_range ||
stream.color_space ||
stream.color_transfer ||
stream.color_primaries) && (
<Chip
size="small"
color={
(stream.color_transfer ?? "")
.toLowerCase()
.includes("2084") ||
(stream.color_transfer ?? "")
.toLowerCase()
.includes("b67") ||
(stream.color_space ?? "")
.toLowerCase()
.includes("bt2020") ||
(stream.color_primaries ?? "")
.toLowerCase()
.includes("bt2020")
? "warning"
: "default"
}
variant="outlined"
label={[
stream.color_range,
stream.color_space,
stream.color_transfer,
stream.color_primaries,
]
.filter(Boolean)
.join(" / ")}
/>
)}
</Stack>
<Typography variant="body2" sx={{ mt: 0.75 }}>
{stream.tags?.language
? `Language: ${stream.tags.language}. `
: ""}
{stream.tags?.title
? `Title: ${stream.tags.title}.`
: ""}
</Typography>
</Box>
))}
</Stack>
</Box>
)}
{audioStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Audio streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{audioStreams.map((stream, index) => (
<Box
key={`audio-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="secondary"
label={stream.codec_type ?? "audio"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.channels && (
<Chip
size="small"
variant="outlined"
label={`${stream.channels} ch`}
/>
)}
{stream.sample_rate && (
<Chip
size="small"
variant="outlined"
label={`${stream.sample_rate} Hz`}
/>
)}
{stream.bit_rate && (
<Chip
size="small"
variant="outlined"
label={humanRate(stream.bit_rate)}
/>
)}
{stream.duration && (
<Chip
size="small"
variant="outlined"
label={humanDuration(stream.duration)}
/>
)}
</Stack>
<Typography variant="body2" sx={{ mt: 0.75 }}>
{stream.codec_long_name
? `${stream.codec_long_name}. `
: ""}
{stream.channel_layout
? `Layout: ${stream.channel_layout}. `
: ""}
{stream.tags?.language
? `Language: ${stream.tags.language}. `
: ""}
{stream.tags?.title
? `Title: ${stream.tags.title}.`
: ""}
</Typography>
</Box>
))}
</Stack>
</Box>
)}
{subtitleStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Subtitle streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{subtitleStreams.map((stream, index) => (
<Box
key={`subtitle-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="info"
label={stream.codec_type ?? "subtitle"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.tags?.language && (
<Chip
size="small"
variant="outlined"
label={stream.tags.language}
/>
)}
{stream.tags?.title && (
<Chip
size="small"
variant="outlined"
label={stream.tags.title}
/>
)}
</Stack>
</Box>
))}
</Stack>
</Box>
)}
{streams.length === 0 && (
<Typography variant="body2" color="text.secondary">
No streams found.
</Typography>
)}
</Stack>
</CardContent>
</Card>
{Object.keys(format.tags ?? {}).length > 0 && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Tags
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
{Object.entries(format.tags ?? {}).map(([key, value]) => (
<Chip
key={key}
size="small"
label={`${key}: ${value}`}
variant="outlined"
/>
))}
</Stack>
</CardContent>
</Card>
)}
</Stack>
);
}
export function FileBrowser() {
const [searchParams] = useSearchParams();
const isMobile = useMediaQuery("(max-width: 900px)");
const initialRequestedPath = searchParams.get("path");
const [browserState, setBrowserState] = usePersistentState<FileBrowserState>(
FILE_BROWSER_STATE_KEY,
() => {
const requestedPath = initialRequestedPath ?? "/";
const selectedPath =
requestedPath !== "/" &&
(isVideoFile(requestedPath) || requestedPath.includes("."))
? requestedPath.replace(/\/+$/, "")
: null;
const currentDir = selectedPath
? selectedPath.replace(/\/[^/]+$/, "") || "/"
: requestedPath.replace(/\/+$/, "") || "/";
return {
...defaultFileBrowserState(),
currentDir,
pathInput: requestedPath || currentDir,
selectedPath,
};
},
);
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
setBrowserState((current) => ({ ...current, ...patch }));
const {
data: listing,
isLoading,
error,
refetch,
} = useDirectoryListing(currentDir);
const {
data: ffprobeData,
isLoading: ffprobeLoading,
error: ffprobeError,
} = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
);
const { data: templates } = useJobTemplates();
const runJob = useRunJob();
const navigate = (path: string) => {
updateBrowserState({
currentDir: path,
pathInput: path,
selectedPath: null,
});
};
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") navigate(pathInput || "/");
};
const rows: DisplayRow[] = [];
if (currentDir !== "/") {
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
rows.push({
id: `up-${parent}`,
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() ?? "") : "";
const path = `${currentDir === "/" ? "" : currentDir}/${entry.name}`;
rows.push({
id: path,
type: kind,
name: entry.name,
ext,
size: kind === "dir" ? "-" : formatSize(entry.size),
modified: formatTime(entry.mtime),
path,
});
}
}
const columns: GridColDef<DisplayRow>[] = [
{ field: "type", headerName: "Type", width: 90 },
{ field: "name", headerName: "Name", flex: 1.2, minWidth: 220 },
{ field: "ext", headerName: "Ext", width: 90 },
{ field: "size", headerName: "Size", width: 120 },
{ field: "modified", headerName: "Modified", width: 190 },
];
const rowSelectionModel: GridRowSelectionModel = selectedPath
? { type: "include", ids: new Set([selectedPath]) }
: { type: "include", ids: new Set() };
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
return (
<Stack spacing={2}>
<Typography variant="h5">File Browser</Typography>
<Stack direction={isMobile ? "column" : "row"} spacing={1}>
<TextField
fullWidth
size="small"
label="Remote path"
value={pathInput}
onChange={(e) => updateBrowserState({ pathInput: e.target.value })}
onKeyDown={handlePathSubmit}
/>
<Button
fullWidth={isMobile}
variant="outlined"
onClick={() => navigate(pathInput || "/")}
>
Open
</Button>
<Button
fullWidth={isMobile}
variant="outlined"
onClick={() => refetch()}
>
Refresh
</Button>
</Stack>
<Typography variant="caption" color="text.secondary">
Current: {currentDir}{" "}
{selectedPath ? `| Selected: ${selectedPath}` : ""}{" "}
{listing ? `| Entries: ${listing.count}` : ""}
</Typography>
{error && <Alert severity="error">{String(error)}</Alert>}
<Box
sx={{
height: 420,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 2,
}}
>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
rowSelectionModel={rowSelectionModel}
columnVisibilityModel={
isMobile ? { ext: false, modified: false } : undefined
}
hideFooter
sx={{
"& .MuiDataGrid-columnHeaders": {
fontWeight: 700,
backgroundColor: "action.hover",
},
}}
onRowClick={(params) => {
const row = params.row as DisplayRow;
if (row.type === "dir" || row.type === "up") navigate(row.path);
else
updateBrowserState({
selectedPath: row.path,
currentDir,
pathInput: row.path,
});
}}
/>
</Box>
{selectedPath && isVideoFile(selectedPath) && (
<Card variant="outlined">
<CardContent>
{ffprobeError ? (
<Alert severity="error" sx={{ mb: 2 }}>
{String(ffprobeError)}
</Alert>
) : ffprobeLoading && !ffprobeData ? (
<Typography color="text.secondary">
Loading ffprobe data...
</Typography>
) : ffprobeData ? (
<FfprobeDetails
path={selectedPath}
data={ffprobeData as FfprobeData}
/>
) : (
<Typography color="text.secondary">
No ffprobe data available.
</Typography>
)}
</CardContent>
</Card>
)}
{selectedPath && templates && templates.length > 0 && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Jobs
</Typography>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth size="small">
<InputLabel>Job template</InputLabel>
<Select
label="Job template"
value={selectedJob}
onChange={(e) =>
updateBrowserState({ selectedJob: e.target.value })
}
>
{templates.map((tpl) => (
<MenuItem key={tpl.key} value={tpl.key}>
{tpl.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<Stack direction={isMobile ? "column" : "row"} spacing={1}>
<Button
variant="contained"
disabled={!selectedJob || runJob.isPending}
onClick={() =>
runJob.mutate({ jobKey: selectedJob, path: selectedPath })
}
>
Run job
</Button>
{selectedTemplate && (
<Typography
variant="body2"
color="text.secondary"
sx={{ alignSelf: "center" }}
>
{selectedTemplate.description}
</Typography>
)}
</Stack>
</Grid>
</Grid>
{runJob.data && (
<Box
component="pre"
sx={{
mt: 1.5,
p: 1.5,
bgcolor: "action.hover",
overflow: "auto",
maxHeight: 260,
fontSize: 12,
}}
>
Exit: {runJob.data.exit_status}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</Box>
)}
</CardContent>
</Card>
)}
</Stack>
);
}
+1 -783
View File
@@ -1,783 +1 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef, GridRowSelectionModel } from "@mui/x-data-grid";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
useMediaQuery,
} from "@mui/material";
import {
useDirectoryListing,
useFfprobe,
useJobTemplates,
useRunJob,
} from "../hooks/useFiles";
interface DisplayRow {
id: string;
type: string;
name: string;
ext: string;
size: string;
modified: string;
path: string;
}
interface FfprobeStream {
index?: number;
codec_type?: string;
codec_name?: string;
codec_long_name?: string;
profile?: string;
width?: number;
height?: number;
bit_rate?: string | number;
duration?: string | number;
channels?: number;
sample_rate?: string | number;
channel_layout?: string;
pix_fmt?: string;
sample_aspect_ratio?: string;
display_aspect_ratio?: string;
field_order?: string;
level?: number | string;
color_range?: string;
color_space?: string;
color_transfer?: string;
color_primaries?: string;
tags?: Record<string, string>;
}
interface FfprobeFormat {
filename?: string;
format_name?: string;
format_long_name?: string;
duration?: string | number;
size?: string | number;
bit_rate?: string | number;
tags?: Record<string, string>;
}
interface FfprobeData {
format?: FfprobeFormat;
streams?: FfprobeStream[];
}
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 humanBytes(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const bytes = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(bytes)) return "-";
return formatSize(bytes);
}
function humanRate(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const rate = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(rate)) return "-";
const units = ["bps", "Kbps", "Mbps", "Gbps"];
let v = rate;
let unitIdx = 0;
while (v >= 1000 && unitIdx < units.length - 1) {
v /= 1000;
unitIdx++;
}
return `${v.toFixed(1)} ${units[unitIdx]}`;
}
function humanDuration(value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
const seconds = typeof value === "string" ? Number(value) : value;
if (!Number.isFinite(seconds)) return "-";
const total = Math.max(0, Math.round(seconds));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
if (hours > 0)
return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
return `${minutes}:${String(secs).padStart(2, "0")}`;
}
function fieldLabel(_key: string, value: string | number | undefined): string {
if (value === undefined || value === null || value === "") return "-";
return String(value);
}
function isVideoFile(name: string): boolean {
const exts = [
".mkv",
".mp4",
".avi",
".m4v",
".ts",
".wmv",
".mov",
".flv",
".webm",
];
return exts.some((ext) => name.toLowerCase().endsWith(ext));
}
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
const format = data.format ?? {};
const streams = data.streams ?? [];
const videoStreams = streams.filter(
(stream) => stream.codec_type === "video",
);
const audioStreams = streams.filter(
(stream) => stream.codec_type === "audio",
);
const subtitleStreams = streams.filter(
(stream) => stream.codec_type === "subtitle",
);
return (
<Stack spacing={2}>
<Box>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>
ffprobe details
</Typography>
<Typography variant="caption" color="text.secondary">
{path}
</Typography>
</Box>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Container / format
</Typography>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body2">
<b>Format:</b> {fieldLabel("format", format.format_name)}
</Typography>
<Typography variant="body2">
<b>Long name:</b>{" "}
{fieldLabel("format_long_name", format.format_long_name)}
</Typography>
<Typography variant="body2">
<b>Duration:</b> {humanDuration(format.duration)}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="body2">
<b>Size:</b> {humanBytes(format.size)}
</Typography>
<Typography variant="body2">
<b>Bitrate:</b> {humanRate(format.bit_rate)}
</Typography>
<Typography variant="body2">
<b>Filename:</b> {fieldLabel("filename", format.filename)}
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Streams
</Typography>
<Stack spacing={1.5}>
{videoStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Video streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{videoStreams.map((stream, index) => (
<Box
key={`video-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="primary"
label={stream.codec_type ?? "video"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.codec_long_name && (
<Chip
size="small"
variant="outlined"
label={stream.codec_long_name}
/>
)}
{stream.profile && (
<Chip
size="small"
variant="outlined"
label={stream.profile}
/>
)}
{stream.bit_rate && (
<Chip
size="small"
variant="outlined"
label={humanRate(stream.bit_rate)}
/>
)}
{stream.duration && (
<Chip
size="small"
variant="outlined"
label={humanDuration(stream.duration)}
/>
)}
{stream.width && stream.height && (
<Chip
size="small"
variant="outlined"
label={`${stream.width}×${stream.height}`}
/>
)}
{stream.pix_fmt && (
<Chip
size="small"
variant="outlined"
label={stream.pix_fmt}
/>
)}
{stream.display_aspect_ratio && (
<Chip
size="small"
variant="outlined"
label={`DAR ${stream.display_aspect_ratio}`}
/>
)}
{stream.sample_aspect_ratio && (
<Chip
size="small"
variant="outlined"
label={`SAR ${stream.sample_aspect_ratio}`}
/>
)}
{stream.level !== undefined &&
stream.level !== null && (
<Chip
size="small"
variant="outlined"
label={`L${stream.level}`}
/>
)}
{stream.field_order &&
stream.field_order !== "unknown" && (
<Chip
size="small"
variant="outlined"
label={stream.field_order}
/>
)}
{(stream.color_range ||
stream.color_space ||
stream.color_transfer ||
stream.color_primaries) && (
<Chip
size="small"
color={
(stream.color_transfer ?? "")
.toLowerCase()
.includes("2084") ||
(stream.color_transfer ?? "")
.toLowerCase()
.includes("b67") ||
(stream.color_space ?? "")
.toLowerCase()
.includes("bt2020") ||
(stream.color_primaries ?? "")
.toLowerCase()
.includes("bt2020")
? "warning"
: "default"
}
variant="outlined"
label={[
stream.color_range,
stream.color_space,
stream.color_transfer,
stream.color_primaries,
]
.filter(Boolean)
.join(" / ")}
/>
)}
</Stack>
<Typography variant="body2" sx={{ mt: 0.75 }}>
{stream.tags?.language
? `Language: ${stream.tags.language}. `
: ""}
{stream.tags?.title
? `Title: ${stream.tags.title}.`
: ""}
</Typography>
</Box>
))}
</Stack>
</Box>
)}
{audioStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Audio streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{audioStreams.map((stream, index) => (
<Box
key={`audio-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="secondary"
label={stream.codec_type ?? "audio"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.channels && (
<Chip
size="small"
variant="outlined"
label={`${stream.channels} ch`}
/>
)}
{stream.sample_rate && (
<Chip
size="small"
variant="outlined"
label={`${stream.sample_rate} Hz`}
/>
)}
{stream.bit_rate && (
<Chip
size="small"
variant="outlined"
label={humanRate(stream.bit_rate)}
/>
)}
{stream.duration && (
<Chip
size="small"
variant="outlined"
label={humanDuration(stream.duration)}
/>
)}
</Stack>
<Typography variant="body2" sx={{ mt: 0.75 }}>
{stream.codec_long_name
? `${stream.codec_long_name}. `
: ""}
{stream.channel_layout
? `Layout: ${stream.channel_layout}. `
: ""}
{stream.tags?.language
? `Language: ${stream.tags.language}. `
: ""}
{stream.tags?.title
? `Title: ${stream.tags.title}.`
: ""}
</Typography>
</Box>
))}
</Stack>
</Box>
)}
{subtitleStreams.length > 0 && (
<Box>
<Typography variant="caption" color="text.secondary">
Subtitle streams
</Typography>
<Stack spacing={1} sx={{ mt: 0.75 }}>
{subtitleStreams.map((stream, index) => (
<Box
key={`subtitle-${stream.index ?? index}`}
sx={{
p: 1.25,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap", alignItems: "center" }}
>
<Chip
size="small"
label={`#${stream.index ?? index}`}
/>
<Chip
size="small"
color="info"
label={stream.codec_type ?? "subtitle"}
/>
<Chip
size="small"
variant="outlined"
label={stream.codec_name ?? "unknown codec"}
/>
{stream.tags?.language && (
<Chip
size="small"
variant="outlined"
label={stream.tags.language}
/>
)}
{stream.tags?.title && (
<Chip
size="small"
variant="outlined"
label={stream.tags.title}
/>
)}
</Stack>
</Box>
))}
</Stack>
</Box>
)}
{streams.length === 0 && (
<Typography variant="body2" color="text.secondary">
No streams found.
</Typography>
)}
</Stack>
</CardContent>
</Card>
{Object.keys(format.tags ?? {}).length > 0 && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle2" gutterBottom>
Tags
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
{Object.entries(format.tags ?? {}).map(([key, value]) => (
<Chip
key={key}
size="small"
label={`${key}: ${value}`}
variant="outlined"
/>
))}
</Stack>
</CardContent>
</Card>
)}
</Stack>
);
}
export function FileBrowser() {
const [searchParams] = useSearchParams();
const isMobile = useMediaQuery("(max-width: 900px)");
const initialRequestedPath = searchParams.get("path") ?? "/";
const initialSelectedPath =
initialRequestedPath !== "/" &&
(isVideoFile(initialRequestedPath) || initialRequestedPath.includes("."))
? initialRequestedPath.replace(/\/+$/, "")
: null;
const initialCurrentDir = initialSelectedPath
? initialSelectedPath.replace(/\/[^/]+$/, "") || "/"
: initialRequestedPath.replace(/\/+$/, "") || "/";
const [currentDir, setCurrentDir] = useState(initialCurrentDir);
const [pathInput, setPathInput] = useState(initialCurrentDir);
const [selectedPath, setSelectedPath] = useState<string | null>(
initialSelectedPath,
);
const [selectedJob, setSelectedJob] = useState<string>("");
const {
data: listing,
isLoading,
error,
refetch,
} = useDirectoryListing(currentDir);
const {
data: ffprobeData,
isLoading: ffprobeLoading,
error: ffprobeError,
} = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
);
const { data: templates } = useJobTemplates();
const runJob = useRunJob();
const navigate = (path: string) => {
setCurrentDir(path);
setPathInput(path);
setSelectedPath(null);
};
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") navigate(pathInput || "/");
};
const rows: DisplayRow[] = [];
if (currentDir !== "/") {
const parent = currentDir.replace(/\/[^/]+\/?$/, "") || "/";
rows.push({
id: `up-${parent}`,
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() ?? "") : "";
const path = `${currentDir === "/" ? "" : currentDir}/${entry.name}`;
rows.push({
id: path,
type: kind,
name: entry.name,
ext,
size: kind === "dir" ? "-" : formatSize(entry.size),
modified: formatTime(entry.mtime),
path,
});
}
}
const columns: GridColDef<DisplayRow>[] = [
{ field: "type", headerName: "Type", width: 90 },
{ field: "name", headerName: "Name", flex: 1.2, minWidth: 220 },
{ field: "ext", headerName: "Ext", width: 90 },
{ field: "size", headerName: "Size", width: 120 },
{ field: "modified", headerName: "Modified", width: 190 },
];
const rowSelectionModel: GridRowSelectionModel = selectedPath
? { type: "include", ids: new Set([selectedPath]) }
: { type: "include", ids: new Set() };
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
return (
<Stack spacing={2}>
<Typography variant="h5">File Browser</Typography>
<Stack direction={isMobile ? "column" : "row"} spacing={1}>
<TextField
fullWidth
size="small"
label="Remote path"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={handlePathSubmit}
/>
<Button fullWidth={isMobile} variant="outlined" onClick={() => navigate(pathInput || "/")}>
Open
</Button>
<Button fullWidth={isMobile} variant="outlined" onClick={() => refetch()}>
Refresh
</Button>
</Stack>
<Typography variant="caption" color="text.secondary">
Current: {currentDir}{" "}
{selectedPath ? `| Selected: ${selectedPath}` : ""}{" "}
{listing ? `| Entries: ${listing.count}` : ""}
</Typography>
{error && <Alert severity="error">{String(error)}</Alert>}
<Box
sx={{
height: 420,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 2,
}}
>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
rowSelectionModel={rowSelectionModel}
columnVisibilityModel={isMobile ? { ext: false, modified: false } : undefined}
hideFooter
sx={{
"& .MuiDataGrid-columnHeaders": {
fontWeight: 700,
backgroundColor: "action.hover",
},
}}
onRowClick={(params) => {
const row = params.row as DisplayRow;
if (row.type === "dir" || row.type === "up") navigate(row.path);
else setSelectedPath(row.path);
}}
/>
</Box>
{selectedPath && isVideoFile(selectedPath) && (
<Card variant="outlined">
<CardContent>
{ffprobeError ? (
<Alert severity="error" sx={{ mb: 2 }}>
{String(ffprobeError)}
</Alert>
) : ffprobeLoading && !ffprobeData ? (
<Typography color="text.secondary">
Loading ffprobe data...
</Typography>
) : ffprobeData ? (
<FfprobeDetails
path={selectedPath}
data={ffprobeData as FfprobeData}
/>
) : (
<Typography color="text.secondary">
No ffprobe data available.
</Typography>
)}
</CardContent>
</Card>
)}
{selectedPath && templates && templates.length > 0 && (
<Card variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Jobs
</Typography>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth size="small">
<InputLabel>Job template</InputLabel>
<Select
label="Job template"
value={selectedJob}
onChange={(e) => setSelectedJob(e.target.value)}
>
{templates.map((tpl) => (
<MenuItem key={tpl.key} value={tpl.key}>
{tpl.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<Stack direction={isMobile ? "column" : "row"} spacing={1}>
<Button
variant="contained"
disabled={!selectedJob || runJob.isPending}
onClick={() =>
runJob.mutate({ jobKey: selectedJob, path: selectedPath })
}
>
Run job
</Button>
{selectedTemplate && (
<Typography
variant="body2"
color="text.secondary"
sx={{ alignSelf: "center" }}
>
{selectedTemplate.description}
</Typography>
)}
</Stack>
</Grid>
</Grid>
{runJob.data && (
<Box
component="pre"
sx={{
mt: 1.5,
p: 1.5,
bgcolor: "action.hover",
overflow: "auto",
maxHeight: 260,
fontSize: 12,
}}
>
Exit: {runJob.data.exit_status}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</Box>
)}
</CardContent>
</Card>
)}
</Stack>
);
}
export { FileBrowser } from "./FileBrowser.impl";
+45 -18
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef } from "@mui/x-data-grid";
@@ -26,6 +26,7 @@ import {
useStopBuildIndex,
useForceStopBuildIndex,
} from "../hooks/useMedia";
import { usePersistentState } from "../hooks/usePersistentState";
import type { MediaItem } from "../types";
function formatDuration(seconds: number | null | undefined): string {
@@ -39,6 +40,28 @@ function formatDuration(seconds: number | null | undefined): string {
return `${secs}s`;
}
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
type MediaTabState = {
search: string;
types: string;
hdrFilter: string;
sortKey: string;
sortOrder: string;
offset: number;
};
function defaultMediaTabState(): MediaTabState {
return {
search: "",
types: "Movie,Episode",
hdrFilter: "All",
sortKey: "title",
sortOrder: "Ascending",
offset: 0,
};
}
export function Media() {
const navigate = useNavigate();
const isMobile = useMediaQuery("(max-width: 900px)");
@@ -47,13 +70,14 @@ export function Media() {
const stopBuildIndex = useStopBuildIndex();
const forceStopBuildIndex = useForceStopBuildIndex();
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 [mediaState, setMediaState] = usePersistentState<MediaTabState>(
MEDIA_TAB_STATE_KEY,
defaultMediaTabState,
);
const { search, types, hdrFilter, sortKey, sortOrder, offset } = mediaState;
const updateMediaState = (patch: Partial<MediaTabState>) =>
setMediaState((current) => ({ ...current, ...patch }));
const limit = 100;
const { data: queryResult, isLoading } = useMediaDataQuery({
types,
@@ -256,8 +280,7 @@ export function Media() {
size="small"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setOffset(0);
updateMediaState({ search: e.target.value, offset: 0 });
}}
/>
</Grid>
@@ -268,8 +291,7 @@ export function Media() {
label="Types"
value={types}
onChange={(e) => {
setTypes(e.target.value);
setOffset(0);
updateMediaState({ types: e.target.value, offset: 0 });
}}
>
<MenuItem value="Movie,Episode">Movies + Episodes</MenuItem>
@@ -286,8 +308,7 @@ export function Media() {
label="HDR"
value={hdrFilter}
onChange={(e) => {
setHdrFilter(e.target.value);
setOffset(0);
updateMediaState({ hdrFilter: e.target.value, offset: 0 });
}}
>
<MenuItem value="All">All</MenuItem>
@@ -302,7 +323,9 @@ export function Media() {
<Select
label="Sort"
value={sortKey}
onChange={(e) => setSortKey(e.target.value)}
onChange={(e) =>
updateMediaState({ sortKey: e.target.value })
}
>
{[
["title", "Title"],
@@ -327,7 +350,9 @@ export function Media() {
<Select
label="Order"
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
onChange={(e) =>
updateMediaState({ sortOrder: e.target.value })
}
>
<MenuItem value="Ascending">Ascending</MenuItem>
<MenuItem value="Descending">Descending</MenuItem>
@@ -398,7 +423,9 @@ export function Media() {
<Button
variant="outlined"
size="small"
onClick={() => setOffset(Math.max(0, offset - limit))}
onClick={() =>
updateMediaState({ offset: Math.max(0, offset - limit) })
}
disabled={page <= 1}
>
Prev
@@ -409,7 +436,7 @@ export function Media() {
<Button
variant="outlined"
size="small"
onClick={() => setOffset(offset + limit)}
onClick={() => updateMediaState({ offset: offset + limit })}
disabled={page >= totalPages}
>
Next
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff