auto-commit for 296e06eb-544f-4710-845e-c5a611b15f0e

This commit is contained in:
emergent-agent-e1
2026-07-23 23:03:09 +00:00
parent bd8480bd14
commit 211d4c23d5
7 changed files with 303 additions and 38 deletions
+2
View File
@@ -22,6 +22,7 @@ import Shows from "./pages/Shows";
import ShowDetail from "./pages/ShowDetail";
import EpisodePlayer from "./pages/EpisodePlayer";
import SonarrImport from "./pages/SonarrImport";
import AdminShowEpisodes from "./pages/AdminShowEpisodes";
const ProfileGate = ({ children }) => {
const { user, loading: authLoading } = useAuth();
@@ -91,6 +92,7 @@ function App() {
<Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+35 -2
View File
@@ -7,11 +7,15 @@ import { useNavigate, Link } from "react-router-dom";
export default function Admin() {
const nav = useNavigate();
const [movies, setMovies] = useState([]);
const [shows, setShows] = useState([]);
const [transcoding, setTranscoding] = useState({});
const load = async () => {
const { data } = await api.get("/movies");
setMovies(data);
const [{ data: m }, { data: s }] = await Promise.all([
api.get("/movies"),
api.get("/shows").catch(() => ({ data: [] })),
]);
setMovies(m); setShows(s);
};
useEffect(() => { load(); }, []);
@@ -134,6 +138,35 @@ export default function Admin() {
</div>
))}
</div>
{shows.length > 0 && (
<div className="mt-12">
<h2 className="font-display text-2xl font-bold tracking-tight text-white mb-4">Series</h2>
<div className="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-6">Title</span>
<span className="col-span-2">Year</span>
<span className="col-span-2">Episodes</span>
<span className="col-span-2 text-right">Actions</span>
</div>
{shows.map((s) => (
<div key={s.id} className="grid grid-cols-12 items-center px-5 py-4 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`admin-show-row-${s.id}`}>
<div className="col-span-6 flex items-center gap-3 min-w-0">
<img src={s.poster_url} alt="" className="w-10 h-14 object-cover" />
<span className="text-white truncate">{s.title}</span>
</div>
<span className="col-span-2 text-[#8A8A8A] text-sm">{s.year}</span>
<span className="col-span-2 text-[#8A8A8A] text-sm">{s.season_count}s · {s.episode_count}e</span>
<div className="col-span-2 flex justify-end">
<a href={`/admin/shows/${s.id}/episodes`} className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-3 py-1 transition-colors" data-testid={`manage-episodes-${s.id}`}>
Manage Episodes
</a>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
+162
View File
@@ -0,0 +1,162 @@
import { useEffect, useMemo, useState } from "react";
import { useParams, Link } from "react-router-dom";
import api from "../lib/api";
import { toast } from "sonner";
import { ArrowLeft, EyeOff, Eye, Trash2, Zap, CheckSquare, Square } from "lucide-react";
export default function AdminShowEpisodes() {
const { id } = useParams();
const [show, setShow] = useState(null);
const [episodes, setEpisodes] = useState([]);
const [selected, setSelected] = useState(new Set());
const [season, setSeason] = useState("all");
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true);
try {
const [{ data: s }, { data: e }] = await Promise.all([
api.get(`/shows/${id}`),
api.get(`/shows/${id}/episodes?include_hidden=true`),
]);
setShow(s); setEpisodes(e);
} finally { setLoading(false); }
};
useEffect(() => { load(); }, [id]);
const seasons = useMemo(() => [...new Set(episodes.map((e) => e.season_number))].sort((a, b) => a - b), [episodes]);
const visible = useMemo(() => season === "all" ? episodes : episodes.filter((e) => e.season_number === Number(season)), [episodes, season]);
const toggle = (eid) => {
const s = new Set(selected); s.has(eid) ? s.delete(eid) : s.add(eid); setSelected(s);
};
const toggleAll = () => {
if (visible.every((e) => selected.has(e.id))) {
const s = new Set(selected); visible.forEach((e) => s.delete(e.id)); setSelected(s);
} else {
const s = new Set(selected); visible.forEach((e) => s.add(e.id)); setSelected(s);
}
};
const selectSeason = (n) => {
const s = new Set(selected);
episodes.filter((e) => e.season_number === n).forEach((e) => s.add(e.id));
setSelected(s);
};
const bulk = async (action) => {
if (selected.size === 0) return;
const ids = Array.from(selected);
try {
let msg = "";
if (action === "transcode-abr") {
const { data } = await api.post("/episodes/bulk-transcode", { episode_ids: ids, quality: "abr" });
msg = `${data.queued} queued for ABR transcode`;
} else if (action === "transcode-quick") {
const { data } = await api.post("/episodes/bulk-transcode", { episode_ids: ids, quality: "quick" });
msg = `${data.queued} queued for Quick transcode`;
} else if (action === "hide") {
const { data } = await api.post("/episodes/bulk-hide", { episode_ids: ids, hidden: true });
msg = `${data.modified} hidden`;
} else if (action === "unhide") {
const { data } = await api.post("/episodes/bulk-hide", { episode_ids: ids, hidden: false });
msg = `${data.modified} unhidden`;
} else if (action === "delete") {
if (!window.confirm(`Delete ${ids.length} episode(s) permanently? Files on disk are kept, but Kino entries and HLS output are removed.`)) return;
const { data } = await api.post("/episodes/bulk-delete", { episode_ids: ids });
msg = `${data.deleted} deleted`;
}
toast.success(msg);
setSelected(new Set()); load();
} catch (err) {
toast.error(err.response?.data?.detail || "Bulk action failed");
}
};
const statusChip = (ep) => {
const s = ep.hls_status;
const map = { pending: "text-[#fcd34d]", running: "text-[#fcd34d]", done: "text-[#86efac]", failed: "text-[#fca5a5]" };
if (!s) return null;
return <span className={`text-[9px] uppercase tracking-[0.2em] ${map[s] || "text-[#8A8A8A]"}`}>HLS {s}</span>;
};
if (!show) return <div className="min-h-screen bg-[#050505] flex items-center justify-center text-[#8A8A8A]">Loading</div>;
const allSelected = visible.length > 0 && visible.every((e) => selected.has(e.id));
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-32" data-testid={`show-episodes-admin-${show.id}`}>
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<Link to="/admin" className="inline-flex items-center gap-2 text-[#8A8A8A] hover:text-white text-xs uppercase tracking-[0.2em] mb-6" data-testid="back-to-admin">
<ArrowLeft size={14} strokeWidth={1.5} /> Back to Admin
</Link>
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Season Packs</span>
<h1 className="font-display text-4xl md:text-5xl font-black tracking-tighter text-white mt-2" data-testid="admin-show-title">{show.title}</h1>
<p className="text-[#8A8A8A] mt-3">{episodes.length} episodes across {seasons.length} season(s). Select any to bulk transcode, hide, or delete.</p>
{/* Season filter chips */}
<div className="mt-8 flex flex-wrap items-center gap-2">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A] mr-2">View</span>
<button onClick={() => setSeason("all")} className={`text-xs px-4 py-2 border transition-colors ${season === "all" ? "border-[#D9381E] text-white bg-[#D9381E]/10" : "border-[#222] text-[#8A8A8A] hover:text-white"}`} data-testid="season-all">All</button>
{seasons.map((n) => (
<div key={n} className="flex items-center gap-1">
<button onClick={() => setSeason(n)} className={`text-xs px-4 py-2 border transition-colors ${season === n ? "border-[#D9381E] text-white bg-[#D9381E]/10" : "border-[#222] text-[#8A8A8A] hover:text-white"}`} data-testid={`filter-season-${n}`}>Season {n}</button>
<button onClick={() => selectSeason(n)} className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-[#D9381E] transition-colors px-1" title={`Select all S${n} episodes`} data-testid={`select-season-${n}`}>+all</button>
</div>
))}
</div>
{/* Table header */}
<div className="mt-8 border border-[#222]">
<div className="grid grid-cols-12 items-center px-4 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
<button onClick={toggleAll} className="col-span-1 flex items-center gap-2 text-[#8A8A8A] hover:text-white" data-testid="toggle-all-visible">
{allSelected ? <CheckSquare size={16} strokeWidth={1.5} /> : <Square size={16} strokeWidth={1.5} />}
</button>
<span className="col-span-1">S·E</span>
<span className="col-span-5">Title</span>
<span className="col-span-2">Source</span>
<span className="col-span-2">HLS</span>
<span className="col-span-1 text-right">State</span>
</div>
{visible.length === 0 && <div className="p-8 text-center text-[#8A8A8A] text-sm">No episodes.</div>}
{visible.map((ep) => (
<div key={ep.id} className={`grid grid-cols-12 items-center px-4 py-3 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors ${ep.hidden ? "opacity-50" : ""}`} data-testid={`admin-ep-row-${ep.id}`}>
<button onClick={() => toggle(ep.id)} className="col-span-1 flex items-center text-[#8A8A8A] hover:text-white" data-testid={`select-ep-${ep.id}`}>
{selected.has(ep.id) ? <CheckSquare size={16} strokeWidth={1.5} className="text-[#D9381E]" /> : <Square size={16} strokeWidth={1.5} />}
</button>
<span className="col-span-1 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">S{ep.season_number}·E{ep.episode_number}</span>
<div className="col-span-5 min-w-0"><p className="text-white truncate">{ep.title || `Episode ${ep.episode_number}`}</p></div>
<span className="col-span-2 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">{ep.storage_type}</span>
<span className="col-span-2">{statusChip(ep)}</span>
<span className="col-span-1 text-right text-[10px] uppercase tracking-[0.2em]">{ep.hidden ? <span className="text-[#fca5a5]">hidden</span> : <span className="text-[#86efac]">live</span>}</span>
</div>
))}
</div>
</div>
{/* Sticky bulk action bar */}
{selected.size > 0 && (
<div className="fixed bottom-0 left-0 right-0 z-40 bg-[#0F0F0F] border-t border-[#D9381E] px-6 md:px-12 py-4 flex flex-wrap items-center gap-3" data-testid="bulk-action-bar">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">{selected.size} selected</span>
<div className="flex-1 flex flex-wrap gap-2 justify-end">
<button onClick={() => bulk("transcode-quick")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2" data-testid="bulk-transcode-quick">
<Zap size={14} strokeWidth={1.5} /> Quick HLS
</button>
<button onClick={() => bulk("transcode-abr")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2" data-testid="bulk-transcode-abr">
<Zap size={14} strokeWidth={1.5} /> ABR HLS
</button>
<button onClick={() => bulk("hide")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2" data-testid="bulk-hide">
<EyeOff size={14} strokeWidth={1.5} /> Hide
</button>
<button onClick={() => bulk("unhide")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2" data-testid="bulk-unhide">
<Eye size={14} strokeWidth={1.5} /> Unhide
</button>
<button onClick={() => bulk("delete")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-4 py-2" data-testid="bulk-delete">
<Trash2 size={14} strokeWidth={1.5} /> Delete
</button>
<button onClick={() => setSelected(new Set())} className="text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white px-4 py-2" data-testid="clear-selection">Clear</button>
</div>
</div>
)}
</div>
);
}