mirror of
https://github.com/myronblair/kino-app
synced 2026-07-27 21:18:43 -05:00
auto-commit for b5490b7b-d57f-4f53-9a99-bd8ed84027db
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user