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
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"env_image_name": "fastapi_react_mongo_shadcn_base_image_cloud_arm:release-17042026-1", "env_image_name": "fastapi_react_mongo_shadcn_base_image_cloud_arm:release-17042026-1",
"job_id": "08d9a7a1-2a0c-4502-9939-fc5d643c04c4", "job_id": "08d9a7a1-2a0c-4502-9939-fc5d643c04c4",
"created_at": "2026-07-23T22:02:35.449854+00:00Z" "created_at": "2026-07-23T22:57:32.873830+00:00Z"
} }
+8 -4
View File
@@ -260,6 +260,9 @@ class Episode(EpisodeBase):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
sonarr_episode_id: Optional[int] = None sonarr_episode_id: Optional[int] = None
hidden: bool = False
hls_path: Optional[str] = None
hls_status: Optional[str] = None
created_at: str = Field(default_factory=_now_iso) created_at: str = Field(default_factory=_now_iso)
@@ -313,12 +316,13 @@ class OpenSubsResult(BaseModel):
class TranscodeJob(BaseModel): class TranscodeJob(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
movie_id: str movie_id: str # generic content id (movie_id OR episode_id, depending on content_type)
movie_title: str = "" movie_title: str = ""
quality: str = "abr" # quick | abr content_type: str = "movie" # movie | episode
status: str = "pending" # pending | running | done | failed | cancelled quality: str = "abr"
status: str = "pending"
error: str = "" error: str = ""
triggered_by: str = "manual" # manual | auto triggered_by: str = "manual"
created_at: str = Field(default_factory=_now_iso) created_at: str = Field(default_factory=_now_iso)
started_at: Optional[str] = None started_at: Optional[str] = None
finished_at: Optional[str] = None finished_at: Optional[str] = None
+84 -31
View File
@@ -492,22 +492,27 @@ async def _set_hls_status(movie_id: str, status: str, path: Optional[str] = None
async def _process_job(job: dict): async def _process_job(job: dict):
"""Run a transcode job. Updates both movie.hls_status and the job row.""" """Run a transcode job. Handles both movies and episodes."""
movie = await db.movies.find_one({"id": job["movie_id"]}, {"_id": 0}) content_type = job.get("content_type", "movie")
if not movie: coll = db.movies if content_type == "movie" else db.episodes
doc = await coll.find_one({"id": job["movie_id"]}, {"_id": 0})
if not doc:
await db.transcode_queue.update_one( await db.transcode_queue.update_one(
{"id": job["id"]}, {"id": job["id"]},
{"$set": {"status": "failed", "error": "Movie not found", "finished_at": datetime.now(timezone.utc).isoformat()}}, {"$set": {"status": "failed", "error": f"{content_type} not found", "finished_at": datetime.now(timezone.utc).isoformat()}},
) )
return return
if movie.get("storage_type") not in ("local", "radarr") or not movie.get("storage_path"): if doc.get("storage_type") not in ("local", "radarr", "sonarr") or not doc.get("storage_path"):
await db.transcode_queue.update_one( await db.transcode_queue.update_one(
{"id": job["id"]}, {"id": job["id"]},
{"$set": {"status": "failed", "error": "Not a local/radarr movie", "finished_at": datetime.now(timezone.utc).isoformat()}}, {"$set": {"status": "failed", "error": "Not a local file", "finished_at": datetime.now(timezone.utc).isoformat()}},
) )
return return
source = Path(movie["storage_path"]) if movie["storage_type"] == "radarr" else VIDEOS_DIR / movie["storage_path"] source = (
Path(doc["storage_path"]) if doc["storage_type"] in ("radarr", "sonarr")
else VIDEOS_DIR / doc["storage_path"]
)
out_dir = HLS_DIR / job["movie_id"] out_dir = HLS_DIR / job["movie_id"]
import shutil as _sh import shutil as _sh
_sh.rmtree(out_dir, ignore_errors=True) _sh.rmtree(out_dir, ignore_errors=True)
@@ -515,12 +520,10 @@ async def _process_job(job: dict):
last_error = {"msg": ""} last_error = {"msg": ""}
async def cb(status, entry: Optional[str] = None, error: Optional[str] = None): async def cb(status, entry: Optional[str] = None, error: Optional[str] = None):
if status == "done" and entry: update = {"hls_status": status}
await _set_hls_status(job["movie_id"], "done", path=entry) if status == "done" and entry: update["hls_path"] = entry
else: await coll.update_one({"id": job["movie_id"]}, {"$set": update})
await _set_hls_status(job["movie_id"], status, error=error) if error: last_error["msg"] = error
if error:
last_error["msg"] = error
try: try:
if job["quality"] == "abr": if job["quality"] == "abr":
@@ -530,17 +533,13 @@ async def _process_job(job: dict):
except Exception as e: except Exception as e:
logger.exception("transcode crashed") logger.exception("transcode crashed")
last_error["msg"] = str(e) last_error["msg"] = str(e)
await _set_hls_status(job["movie_id"], "failed", error=str(e)) await coll.update_one({"id": job["movie_id"]}, {"$set": {"hls_status": "failed"}})
movie_after = await db.movies.find_one({"id": job["movie_id"]}, {"_id": 0, "hls_status": 1}) after = await coll.find_one({"id": job["movie_id"]}, {"_id": 0, "hls_status": 1})
final_status = "done" if movie_after and movie_after.get("hls_status") == "done" else "failed" final_status = "done" if after and after.get("hls_status") == "done" else "failed"
await db.transcode_queue.update_one( await db.transcode_queue.update_one(
{"id": job["id"]}, {"id": job["id"]},
{"$set": { {"$set": {"status": final_status, "error": last_error["msg"], "finished_at": datetime.now(timezone.utc).isoformat()}},
"status": final_status,
"error": last_error["msg"],
"finished_at": datetime.now(timezone.utc).isoformat(),
}},
) )
@@ -570,22 +569,23 @@ async def _transcode_worker():
await asyncio.sleep(10) await asyncio.sleep(10)
async def _enqueue_transcode(movie_id: str, quality: str, triggered_by: str = "manual") -> dict: async def _enqueue_transcode(content_id: str, quality: str, triggered_by: str = "manual", content_type: str = "movie") -> dict:
"""Add a job to the queue. Returns the job document.""" """Add a job to the queue. Returns the job document. Works for movies + episodes."""
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0, "title": 1}) coll = db.movies if content_type == "movie" else db.episodes
title = movie.get("title", "") if movie else "" doc = await coll.find_one({"id": content_id}, {"_id": 0, "title": 1})
# Skip if a non-finished job for this movie already exists title = doc.get("title", "") if doc else ""
existing = await db.transcode_queue.find_one( existing = await db.transcode_queue.find_one(
{"movie_id": movie_id, "status": {"$in": ["pending", "running"]}}, {"movie_id": content_id, "status": {"$in": ["pending", "running"]}},
{"_id": 0}, {"_id": 0},
) )
if existing: if existing:
return existing return existing
job = TranscodeJob( job = TranscodeJob(
movie_id=movie_id, movie_title=title, quality=quality, triggered_by=triggered_by, movie_id=content_id, movie_title=title, quality=quality,
triggered_by=triggered_by, content_type=content_type,
).model_dump() ).model_dump()
await db.transcode_queue.insert_one(job) await db.transcode_queue.insert_one(job)
await _set_hls_status(movie_id, "pending") await coll.update_one({"id": content_id}, {"$set": {"hls_status": "pending"}})
return job return job
@@ -1042,8 +1042,11 @@ async def delete_show(show_id: str, user: dict = Depends(require_admin)):
@api.get("/shows/{show_id}/episodes", response_model=List[Episode]) @api.get("/shows/{show_id}/episodes", response_model=List[Episode])
async def list_episodes(show_id: str, user: dict = Depends(get_current_user)): async def list_episodes(show_id: str, include_hidden: bool = False, user: dict = Depends(get_current_user)):
docs = await db.episodes.find({"show_id": show_id}, {"_id": 0}).sort([("season_number", 1), ("episode_number", 1)]).to_list(2000) q: dict = {"show_id": show_id}
if not include_hidden or not user.get("is_admin"):
q["hidden"] = {"$ne": True}
docs = await db.episodes.find(q, {"_id": 0}).sort([("season_number", 1), ("episode_number", 1)]).to_list(2000)
return docs return docs
@@ -1067,6 +1070,56 @@ async def create_episode(payload: EpisodeCreate, user: dict = Depends(require_ad
return _strip(doc) return _strip(doc)
# ============ Episode bulk actions (admin) ============
@api.post("/episodes/bulk-transcode")
async def bulk_transcode_eps(payload: dict, user: dict = Depends(require_admin)):
ids = payload.get("episode_ids") or []
quality = payload.get("quality", "abr")
if quality not in ("quick", "abr"): raise HTTPException(status_code=400, detail="Invalid quality")
queued = 0
for eid in ids:
ep = await db.episodes.find_one({"id": eid}, {"_id": 0})
if not ep or ep.get("hls_status") in ("running", "pending"): continue
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"): continue
await _enqueue_transcode(eid, quality, triggered_by="manual", content_type="episode")
queued += 1
return {"ok": True, "queued": queued}
@api.post("/episodes/bulk-hide")
async def bulk_hide_eps(payload: dict, user: dict = Depends(require_admin)):
ids = payload.get("episode_ids") or []
hidden = bool(payload.get("hidden", True))
res = await db.episodes.update_many({"id": {"$in": ids}}, {"$set": {"hidden": hidden}})
return {"ok": True, "modified": res.modified_count}
@api.post("/episodes/bulk-delete")
async def bulk_delete_eps(payload: dict, user: dict = Depends(require_admin)):
ids = payload.get("episode_ids") or []
import shutil as _sh
for eid in ids:
_sh.rmtree(HLS_DIR / eid, ignore_errors=True)
res = await db.episodes.delete_many({"id": {"$in": ids}})
await db.episode_progress.delete_many({"episode_id": {"$in": ids}})
await db.subtitles.delete_many({"episode_id": {"$in": ids}})
return {"ok": True, "deleted": res.deleted_count}
@api.post("/episodes/{episode_id}/transcode")
async def transcode_episode(episode_id: str, payload: Optional[dict] = None, user: dict = Depends(require_admin)):
quality = (payload or {}).get("quality", "abr")
if quality not in ("quick", "abr"): raise HTTPException(status_code=400, detail="Invalid quality")
ep = await db.episodes.find_one({"id": episode_id}, {"_id": 0})
if not ep: raise HTTPException(status_code=404, detail="Episode not found")
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"):
raise HTTPException(status_code=400, detail="Only local/sonarr episodes can be transcoded")
if ep.get("hls_status") in ("running", "pending"):
raise HTTPException(status_code=409, detail="Already transcoding or queued")
job = await _enqueue_transcode(episode_id, quality, triggered_by="manual", content_type="episode")
return {"ok": True, "job_id": job["id"]}
@api.get("/stream/episode/{episode_id}") @api.get("/stream/episode/{episode_id}")
async def stream_episode( async def stream_episode(
episode_id: str, request: Request, episode_id: str, request: Request,
+2
View File
@@ -22,6 +22,7 @@ import Shows from "./pages/Shows";
import ShowDetail from "./pages/ShowDetail"; import ShowDetail from "./pages/ShowDetail";
import EpisodePlayer from "./pages/EpisodePlayer"; import EpisodePlayer from "./pages/EpisodePlayer";
import SonarrImport from "./pages/SonarrImport"; import SonarrImport from "./pages/SonarrImport";
import AdminShowEpisodes from "./pages/AdminShowEpisodes";
const ProfileGate = ({ children }) => { const ProfileGate = ({ children }) => {
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
@@ -91,6 +92,7 @@ function App() {
<Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} /> <Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} /> <Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></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="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
+35 -2
View File
@@ -7,11 +7,15 @@ import { useNavigate, Link } from "react-router-dom";
export default function Admin() { export default function Admin() {
const nav = useNavigate(); const nav = useNavigate();
const [movies, setMovies] = useState([]); const [movies, setMovies] = useState([]);
const [shows, setShows] = useState([]);
const [transcoding, setTranscoding] = useState({}); const [transcoding, setTranscoding] = useState({});
const load = async () => { const load = async () => {
const { data } = await api.get("/movies"); const [{ data: m }, { data: s }] = await Promise.all([
setMovies(data); api.get("/movies"),
api.get("/shows").catch(() => ({ data: [] })),
]);
setMovies(m); setShows(s);
}; };
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
@@ -134,6 +138,35 @@ export default function Admin() {
</div> </div>
))} ))}
</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>
</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>
);
}
+11
View File
@@ -60,6 +60,17 @@
- **Crash recovery**: stuck `running` jobs are reset to `failed` on backend restart - **Crash recovery**: stuck `running` jobs are reset to `failed` on backend restart
- Cancel of a pending job clears `hls_path` to avoid stale references - Cancel of a pending job clears `hls_path` to avoid stale references
### Phase 6 (2026-07-23)
- **Season packs** — new `/admin/shows/:id/episodes` grid: season filter chips, +all-in-season selector, per-row and select-all checkboxes, sticky bulk action bar
- **Bulk actions**: Quick HLS transcode, ABR HLS transcode, Hide, Unhide, Delete (with confirm)
- **Episode transcode queue** — extended queue schema with `content_type` so episodes ride the same FIFO worker as movies
- **Hidden episodes** — filtered out of `/shows/{id}/episodes` for members
- **Admin dashboard** now has a "Series" section listing every imported show with a "Manage Episodes" link
- **Auto-subtitles + auto-transcode on Sonarr/Radarr import** — imports are hands-off; bulk actions are for after-the-fact curation
## Rebrand voice
No Netflix-isms — Library / Series / Shelf / Wishlist / Watch / Resume / Popular / Fresh Arrivals / In the Spotlight / Choose a viewer.
### Phase 5 (2026-07-23) ### Phase 5 (2026-07-23)
- **Sonarr / TV Shows** — Show → Episode data model, Sonarr library import (multi-select), season/episode picker UI, dedicated `/shows` browse page - **Sonarr / TV Shows** — Show → Episode data model, Sonarr library import (multi-select), season/episode picker UI, dedicated `/shows` browse page
- **Episode player** with auto-next-episode CTA in last 20s, auto-advance on video end, profile-scoped progress + continue-watching - **Episode player** with auto-next-episode CTA in last 20s, auto-advance on video end, profile-scoped progress + continue-watching