auto-commit for b5490b7b-d57f-4f53-9a99-bd8ed84027db

This commit is contained in:
emergent-agent-e1
2026-04-29 16:33:26 +00:00
parent 966ab6a34f
commit da8a2903b4
8 changed files with 698 additions and 21 deletions
+2
View File
@@ -17,6 +17,7 @@ import AdminUpload from "./pages/AdminUpload";
import Settings from "./pages/Settings";
import RadarrImport from "./pages/RadarrImport";
import ProfileSelect from "./pages/ProfileSelect";
import TranscodeQueue from "./pages/TranscodeQueue";
const ProfileGate = ({ children }) => {
const { user, loading: authLoading } = useAuth();
@@ -82,6 +83,7 @@ function App() {
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
<Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Shell>
+3
View File
@@ -81,6 +81,9 @@ export default function Admin() {
<Link to="/admin/radarr" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-radarr-link">
<Download size={14} strokeWidth={1.5} /> Radarr Import
</Link>
<Link to="/admin/queue" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-queue-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Queue
</Link>
<Link to="/admin/settings" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-settings-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
</Link>
+31 -3
View File
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { Eye, EyeOff } from "lucide-react";
import { Eye, EyeOff, ListTodo } from "lucide-react";
export default function Settings() {
const [s, setS] = useState({ tmdb_api_key: "", radarr_url: "", radarr_api_key: "" });
const [s, setS] = useState({ tmdb_api_key: "", radarr_url: "", radarr_api_key: "", auto_transcode: "off", queue_paused: false });
const [info, setInfo] = useState({ tmdb_configured: false, radarr_configured: false });
const [showTmdb, setShowTmdb] = useState(false);
const [showRadarr, setShowRadarr] = useState(false);
@@ -12,7 +12,13 @@ export default function Settings() {
const load = async () => {
const { data } = await api.get("/settings");
setS({ tmdb_api_key: data.tmdb_api_key || "", radarr_url: data.radarr_url || "", radarr_api_key: data.radarr_api_key || "" });
setS({
tmdb_api_key: data.tmdb_api_key || "",
radarr_url: data.radarr_url || "",
radarr_api_key: data.radarr_api_key || "",
auto_transcode: data.auto_transcode || "off",
queue_paused: !!data.queue_paused,
});
setInfo({ tmdb_configured: data.tmdb_configured, radarr_configured: data.radarr_configured });
};
useEffect(() => { load(); }, []);
@@ -106,6 +112,28 @@ export default function Settings() {
</button>
</section>
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display text-2xl font-bold text-white flex items-center gap-2">
<ListTodo size={20} strokeWidth={1.5} className="text-[#D9381E]" /> Transcode Queue
</h2>
<a href="/admin/queue" className="text-[10px] uppercase tracking-[0.3em] text-[#D9381E] hover:text-[#ED4B32]" data-testid="settings-queue-link">View queue </a>
</div>
<p className="text-sm text-[#8A8A8A] mb-4">
Auto-transcode every newly uploaded or imported movie. Runs at low CPU priority, one at a time.
</p>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Auto-transcode mode</span>
<select value={s.auto_transcode} onChange={(e) => setS({ ...s, auto_transcode: e.target.value })}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="auto-transcode-select">
<option value="off">Off manual only</option>
<option value="quick">Quick (stream-copy, instant, single-rate)</option>
<option value="abr">ABR (re-encoded multi-bitrate, slow but adaptive)</option>
</select>
</label>
</section>
<button type="submit" disabled={saving}
className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]"
data-testid="settings-save-button">
+133
View File
@@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { RefreshCcw, X, RotateCcw, Pause, Play as PlayIcon, Trash2 } from "lucide-react";
const STATUS_COLORS = {
pending: "text-[#fcd34d]",
running: "text-[#fcd34d]",
done: "text-[#86efac]",
failed: "text-[#fca5a5]",
cancelled: "text-[#8A8A8A]",
};
export default function TranscodeQueue() {
const [jobs, setJobs] = useState([]);
const [stats, setStats] = useState({ pending: 0, running: 0, done: 0, failed: 0, cancelled: 0, paused: false, auto_transcode: "off" });
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true);
try {
const [j, s] = await Promise.all([api.get("/transcode/queue"), api.get("/transcode/queue/stats")]);
setJobs(j.data);
setStats(s.data);
} finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
// Poll while jobs are active
useEffect(() => {
const active = jobs.some((j) => j.status === "running" || j.status === "pending");
if (!active) return;
const t = setInterval(load, 4000);
return () => clearInterval(t);
}, [jobs]);
const cancel = async (id) => {
try { await api.delete(`/transcode/queue/${id}`); toast.success("Job cancelled"); load(); }
catch (err) { toast.error(err.response?.data?.detail || "Could not cancel"); }
};
const retry = async (id) => {
try { await api.post(`/transcode/queue/${id}/retry`); toast.success("Re-queued"); load(); }
catch (err) { toast.error(err.response?.data?.detail || "Could not retry"); }
};
const togglePause = async () => {
try {
const { data } = await api.post("/transcode/queue/pause", { paused: !stats.paused });
toast.success(data.paused ? "Queue paused" : "Queue resumed");
load();
} catch { toast.error("Could not toggle pause"); }
};
const clearFinished = async () => {
if (!window.confirm("Remove all done/failed/cancelled jobs from the history?")) return;
try { const { data } = await api.post("/transcode/queue/clear"); toast.success(`Cleared ${data.deleted}`); load(); }
catch { toast.error("Could not clear"); }
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="queue-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Background</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Transcode Queue</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
One job runs at a time at low CPU priority. Auto-transcode is currently:{" "}
<span className="text-white">{stats.auto_transcode === "off" ? "off" : `auto · ${stats.auto_transcode}`}</span>
{" — "}<a href="/admin/settings" className="text-[#D9381E] hover:text-[#ED4B32]">change in Settings</a>
</p>
<div className="mt-8 grid grid-cols-2 sm:grid-cols-5 gap-3">
{["pending", "running", "done", "failed", "cancelled"].map((k) => (
<div key={k} className="border border-[#222] p-4" data-testid={`stat-${k}`}>
<div className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{k}</div>
<div className={`mt-1 font-display text-3xl font-bold ${STATUS_COLORS[k]}`}>{stats[k] || 0}</div>
</div>
))}
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button onClick={load} disabled={loading} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="queue-refresh">
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} /> Refresh
</button>
<button onClick={togglePause} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="queue-pause-toggle">
{stats.paused ? <PlayIcon size={14} strokeWidth={1.5} /> : <Pause size={14} strokeWidth={1.5} />}
{stats.paused ? "Resume" : "Pause"}
</button>
<button onClick={clearFinished} className="flex items-center gap-2 border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="queue-clear">
<Trash2 size={14} strokeWidth={1.5} /> Clear Finished
</button>
</div>
<div className="mt-10 border border-[#222]">
<div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
<span className="col-span-5">Movie</span>
<span className="col-span-1">Quality</span>
<span className="col-span-1">Trigger</span>
<span className="col-span-2">Status</span>
<span className="col-span-2">Created</span>
<span className="col-span-1 text-right">Actions</span>
</div>
{jobs.length === 0 ? (
<div className="p-8 text-center text-[#8A8A8A] text-sm" data-testid="queue-empty">No jobs yet.</div>
) : jobs.map((j) => (
<div key={j.id} className="grid grid-cols-12 items-center px-5 py-3 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`queue-row-${j.id}`}>
<div className="col-span-5 text-white truncate" title={j.movie_title}>{j.movie_title || j.movie_id.slice(0, 8)}</div>
<span className="col-span-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{j.quality}</span>
<span className="col-span-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{j.triggered_by}</span>
<span className={`col-span-2 text-[10px] uppercase tracking-[0.3em] ${STATUS_COLORS[j.status]}`} data-testid={`queue-status-${j.id}`}>
{j.status}
{j.error && j.status === "failed" && <span className="block text-[#fca5a5]/70 normal-case truncate" title={j.error}>{j.error.slice(0, 40)}</span>}
</span>
<span className="col-span-2 text-xs text-[#8A8A8A]">{new Date(j.created_at).toLocaleString()}</span>
<div className="col-span-1 flex justify-end gap-2">
{j.status === "pending" && (
<button onClick={() => cancel(j.id)} className="text-[#8A8A8A] hover:text-[#fca5a5]" data-testid={`cancel-${j.id}`} aria-label="Cancel">
<X size={14} strokeWidth={1.5} />
</button>
)}
{(j.status === "failed" || j.status === "cancelled") && (
<button onClick={() => retry(j.id)} className="text-[#8A8A8A] hover:text-[#D9381E]" data-testid={`retry-${j.id}`} aria-label="Retry">
<RotateCcw size={14} strokeWidth={1.5} />
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}