plug task enqueing properly
This commit is contained in:
@@ -47,6 +47,53 @@ body {
|
||||
background: #202020;
|
||||
border-right: 1px solid #333;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.sidebar-section:first-child {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-count {
|
||||
font-size: 0.7rem;
|
||||
background: #333;
|
||||
color: #888;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-empty {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.output-item {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: #10b981;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.output-item:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@@ -325,27 +372,11 @@ body {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Job list */
|
||||
|
||||
.job-list {
|
||||
margin-top: 0.75rem;
|
||||
border-top: 1px solid #333;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.job-list h3 {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #888;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
/* Job items */
|
||||
|
||||
.job-item {
|
||||
padding: 0.5rem;
|
||||
background: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.375rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@@ -411,25 +442,3 @@ body {
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.job-cancel {
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
border: 1px solid #555;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.job-cancel:hover {
|
||||
color: #ef4444;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.job-error {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { getAssets, getSystemStatus, scanMediaFolder } from "./api";
|
||||
import type { MediaAsset, SystemStatus } from "./types";
|
||||
import { getAssets, getJobs, getSystemStatus, scanMediaFolder } from "./api";
|
||||
import type { MediaAsset, TranscodeJob, SystemStatus } from "./types";
|
||||
import Timeline from "./Timeline";
|
||||
import JobPanel from "./JobPanel";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
const [assets, setAssets] = useState<MediaAsset[]>([]);
|
||||
const [jobs, setJobs] = useState<TranscodeJob[]>([]);
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [selectedAsset, setSelectedAsset] = useState<MediaAsset | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -40,6 +41,24 @@ function App() {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
// Poll jobs
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const fetchJobs = () => {
|
||||
getJobs()
|
||||
.then((data) => {
|
||||
if (active) setJobs(data);
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
fetchJobs();
|
||||
const interval = setInterval(fetchJobs, 3000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset trim state when asset changes
|
||||
useEffect(() => {
|
||||
setTrimStart(0);
|
||||
@@ -48,11 +67,8 @@ function App() {
|
||||
setDuration(0);
|
||||
}, [selectedAsset?.id]);
|
||||
|
||||
// Video event handlers
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
setCurrentTime(videoRef.current.currentTime);
|
||||
}
|
||||
if (videoRef.current) setCurrentTime(videoRef.current.currentTime);
|
||||
}, []);
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
@@ -83,7 +99,6 @@ function App() {
|
||||
alert(
|
||||
`Scan complete!\nFound: ${result.found}\nRegistered: ${result.registered}\nSkipped: ${result.skipped}`,
|
||||
);
|
||||
|
||||
const assetsData = await getAssets();
|
||||
setAssets(
|
||||
assetsData.sort((a, b) => a.filename.localeCompare(b.filename)),
|
||||
@@ -95,13 +110,16 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading...</div>;
|
||||
}
|
||||
const refreshJobs = async () => {
|
||||
const data = await getJobs();
|
||||
setJobs(data);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div className="error">Error: {error}</div>;
|
||||
}
|
||||
const assetJobs = jobs.filter((j) => j.source_asset_id === selectedAsset?.id);
|
||||
const completedJobs = jobs.filter((j) => j.status === "completed");
|
||||
|
||||
if (loading) return <div className="loading">Loading...</div>;
|
||||
if (error) return <div className="error">Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
@@ -116,28 +134,88 @@ function App() {
|
||||
|
||||
<div className="layout">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2>Assets</h2>
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
className="scan-button"
|
||||
>
|
||||
{scanning ? "Scanning..." : "Scan Folder"}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="asset-list">
|
||||
{assets.map((asset) => (
|
||||
<li
|
||||
key={asset.id}
|
||||
className={selectedAsset?.id === asset.id ? "selected" : ""}
|
||||
onClick={() => setSelectedAsset(asset)}
|
||||
title={asset.filename}
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-header">
|
||||
<h2>Assets</h2>
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
className="scan-button"
|
||||
>
|
||||
<span className="filename">{asset.filename}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{scanning ? "Scanning..." : "Scan Folder"}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="asset-list">
|
||||
{assets.map((asset) => (
|
||||
<li
|
||||
key={asset.id}
|
||||
className={selectedAsset?.id === asset.id ? "selected" : ""}
|
||||
onClick={() => setSelectedAsset(asset)}
|
||||
title={asset.filename}
|
||||
>
|
||||
<span className="filename">{asset.filename}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-header">
|
||||
<h2>Jobs</h2>
|
||||
<span className="sidebar-count">{jobs.length}</span>
|
||||
</div>
|
||||
<div className="sidebar-list">
|
||||
{jobs.length === 0 ? (
|
||||
<div className="sidebar-empty">No jobs</div>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<div key={job.id} className="job-item">
|
||||
<div className="job-item-header">
|
||||
<span className="job-filename">
|
||||
{job.output_filename}
|
||||
</span>
|
||||
<span className={`job-status ${job.status}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
{job.status === "processing" && (
|
||||
<div className="job-progress-bar">
|
||||
<div
|
||||
className="job-progress-fill"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-section">
|
||||
<div className="sidebar-header">
|
||||
<h2>Output</h2>
|
||||
<span className="sidebar-count">{completedJobs.length}</span>
|
||||
</div>
|
||||
<div className="sidebar-list">
|
||||
{completedJobs.length === 0 ? (
|
||||
<div className="sidebar-empty">No output files</div>
|
||||
) : (
|
||||
completedJobs.map((job) => (
|
||||
<a
|
||||
key={job.id}
|
||||
className="output-item"
|
||||
href={`/media/out/${job.output_filename}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={job.output_filename}
|
||||
>
|
||||
<span className="filename">{job.output_filename}</span>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
@@ -147,7 +225,7 @@ function App() {
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
src={`/media/${selectedAsset.file_path}`}
|
||||
src={`/media/in/${selectedAsset.file_path}`}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
/>
|
||||
@@ -166,6 +244,7 @@ function App() {
|
||||
asset={selectedAsset}
|
||||
trimStart={trimStart}
|
||||
trimEnd={trimEnd}
|
||||
onJobCreated={refreshJobs}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,49 +1,30 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getPresets, getJobs, createJob, cancelJob } from "./api";
|
||||
import type { MediaAsset, TranscodePreset, TranscodeJob } from "./types";
|
||||
import { getPresets, createJob } from "./api";
|
||||
import type { MediaAsset, TranscodePreset } from "./types";
|
||||
|
||||
interface JobPanelProps {
|
||||
asset: MediaAsset;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
onJobCreated: () => void;
|
||||
}
|
||||
|
||||
export default function JobPanel({ asset, trimStart, trimEnd }: JobPanelProps) {
|
||||
export default function JobPanel({
|
||||
asset,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
onJobCreated,
|
||||
}: JobPanelProps) {
|
||||
const [presets, setPresets] = useState<TranscodePreset[]>([]);
|
||||
const [jobs, setJobs] = useState<TranscodeJob[]>([]);
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Load presets on mount
|
||||
useEffect(() => {
|
||||
getPresets().then(setPresets).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Poll jobs for this asset
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const fetchJobs = () => {
|
||||
getJobs()
|
||||
.then((allJobs) => {
|
||||
if (active) {
|
||||
setJobs(
|
||||
allJobs.filter((j) => j.source_asset_id === asset.id),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
fetchJobs();
|
||||
const interval = setInterval(fetchJobs, 3000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [asset.id]);
|
||||
|
||||
const hasTrim = trimStart > 0 || (asset.duration != null && trimEnd < asset.duration);
|
||||
const hasTrim =
|
||||
trimStart > 0 || (asset.duration != null && trimEnd < asset.duration);
|
||||
const hasPreset = selectedPresetId !== "";
|
||||
const canSubmit = hasTrim || hasPreset;
|
||||
|
||||
@@ -62,9 +43,7 @@ export default function JobPanel({ asset, trimStart, trimEnd }: JobPanelProps) {
|
||||
trim_start: hasTrim ? trimStart : null,
|
||||
trim_end: hasTrim ? trimEnd : null,
|
||||
});
|
||||
// Refresh jobs immediately
|
||||
const allJobs = await getJobs();
|
||||
setJobs(allJobs.filter((j) => j.source_asset_id === asset.id));
|
||||
onJobCreated();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "Failed to create job");
|
||||
} finally {
|
||||
@@ -72,16 +51,6 @@ export default function JobPanel({ asset, trimStart, trimEnd }: JobPanelProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel(jobId: string) {
|
||||
try {
|
||||
await cancelJob(jobId);
|
||||
const allJobs = await getJobs();
|
||||
setJobs(allJobs.filter((j) => j.source_asset_id === asset.id));
|
||||
} catch (e) {
|
||||
console.error("Cancel failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="job-panel">
|
||||
<div className="job-controls">
|
||||
@@ -105,39 +74,6 @@ export default function JobPanel({ asset, trimStart, trimEnd }: JobPanelProps) {
|
||||
{submitting ? "Submitting..." : buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{jobs.length > 0 && (
|
||||
<div className="job-list">
|
||||
<h3>Jobs</h3>
|
||||
{jobs.map((job) => (
|
||||
<div key={job.id} className="job-item">
|
||||
<div className="job-item-header">
|
||||
<span className="job-filename">{job.output_filename}</span>
|
||||
<span className={`job-status ${job.status}`}>{job.status}</span>
|
||||
</div>
|
||||
{job.status === "processing" && (
|
||||
<div className="job-progress-bar">
|
||||
<div
|
||||
className="job-progress-fill"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(job.status === "pending" || job.status === "processing") && (
|
||||
<button
|
||||
className="job-cancel"
|
||||
onClick={() => handleCancel(job.id)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
{job.status === "failed" && job.error_message && (
|
||||
<div className="job-error">{job.error_message}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user