plug task enqueing properly

This commit is contained in:
2026-02-06 10:49:05 -03:00
parent 2cf6c89fbb
commit 013587d108
20 changed files with 413 additions and 356 deletions

View File

@@ -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;
}

View File

@@ -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>
) : (

View File

@@ -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>
);
}