refactor frontend and backend modules
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 +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";
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1110
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user