From 211d4c23d5ee7b676e8e1c9d256b63bdbcaf062e Mon Sep 17 00:00:00 2001 From: emergent-agent-e1 Date: Thu, 23 Jul 2026 23:03:09 +0000 Subject: [PATCH] auto-commit for 296e06eb-544f-4710-845e-c5a611b15f0e --- .emergent/emergent.yml | 2 +- backend/models.py | 12 +- backend/server.py | 115 +++++++++++----- frontend/src/App.js | 2 + frontend/src/pages/Admin.jsx | 37 +++++- frontend/src/pages/AdminShowEpisodes.jsx | 162 +++++++++++++++++++++++ memory/PRD.md | 11 ++ 7 files changed, 303 insertions(+), 38 deletions(-) create mode 100644 frontend/src/pages/AdminShowEpisodes.jsx diff --git a/.emergent/emergent.yml b/.emergent/emergent.yml index d2786c0..1244068 100644 --- a/.emergent/emergent.yml +++ b/.emergent/emergent.yml @@ -1,5 +1,5 @@ { "env_image_name": "fastapi_react_mongo_shadcn_base_image_cloud_arm:release-17042026-1", "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" } diff --git a/backend/models.py b/backend/models.py index 1400fd9..4d0cfcf 100644 --- a/backend/models.py +++ b/backend/models.py @@ -260,6 +260,9 @@ class Episode(EpisodeBase): model_config = ConfigDict(extra="ignore") id: str = Field(default_factory=lambda: str(uuid.uuid4())) 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) @@ -313,12 +316,13 @@ class OpenSubsResult(BaseModel): class TranscodeJob(BaseModel): model_config = ConfigDict(extra="ignore") 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 = "" - quality: str = "abr" # quick | abr - status: str = "pending" # pending | running | done | failed | cancelled + content_type: str = "movie" # movie | episode + quality: str = "abr" + status: str = "pending" error: str = "" - triggered_by: str = "manual" # manual | auto + triggered_by: str = "manual" created_at: str = Field(default_factory=_now_iso) started_at: Optional[str] = None finished_at: Optional[str] = None diff --git a/backend/server.py b/backend/server.py index 0702d22..b6bc276 100644 --- a/backend/server.py +++ b/backend/server.py @@ -492,22 +492,27 @@ async def _set_hls_status(movie_id: str, status: str, path: Optional[str] = None async def _process_job(job: dict): - """Run a transcode job. Updates both movie.hls_status and the job row.""" - movie = await db.movies.find_one({"id": job["movie_id"]}, {"_id": 0}) - if not movie: + """Run a transcode job. Handles both movies and episodes.""" + content_type = job.get("content_type", "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( {"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 - 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( {"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 - 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"] import shutil as _sh _sh.rmtree(out_dir, ignore_errors=True) @@ -515,12 +520,10 @@ async def _process_job(job: dict): last_error = {"msg": ""} async def cb(status, entry: Optional[str] = None, error: Optional[str] = None): - if status == "done" and entry: - await _set_hls_status(job["movie_id"], "done", path=entry) - else: - await _set_hls_status(job["movie_id"], status, error=error) - if error: - last_error["msg"] = error + update = {"hls_status": status} + if status == "done" and entry: update["hls_path"] = entry + await coll.update_one({"id": job["movie_id"]}, {"$set": update}) + if error: last_error["msg"] = error try: if job["quality"] == "abr": @@ -530,17 +533,13 @@ async def _process_job(job: dict): except Exception as e: logger.exception("transcode crashed") 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}) - final_status = "done" if movie_after and movie_after.get("hls_status") == "done" else "failed" + after = await coll.find_one({"id": job["movie_id"]}, {"_id": 0, "hls_status": 1}) + final_status = "done" if after and after.get("hls_status") == "done" else "failed" await db.transcode_queue.update_one( {"id": job["id"]}, - {"$set": { - "status": final_status, - "error": last_error["msg"], - "finished_at": datetime.now(timezone.utc).isoformat(), - }}, + {"$set": {"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) -async def _enqueue_transcode(movie_id: str, quality: str, triggered_by: str = "manual") -> dict: - """Add a job to the queue. Returns the job document.""" - movie = await db.movies.find_one({"id": movie_id}, {"_id": 0, "title": 1}) - title = movie.get("title", "") if movie else "" - # Skip if a non-finished job for this movie already exists +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. Works for movies + episodes.""" + coll = db.movies if content_type == "movie" else db.episodes + doc = await coll.find_one({"id": content_id}, {"_id": 0, "title": 1}) + title = doc.get("title", "") if doc else "" 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}, ) if existing: return existing 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() 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 @@ -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]) -async def list_episodes(show_id: str, 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) +async def list_episodes(show_id: str, include_hidden: bool = False, user: dict = Depends(get_current_user)): + 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 @@ -1067,6 +1070,56 @@ async def create_episode(payload: EpisodeCreate, user: dict = Depends(require_ad 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}") async def stream_episode( episode_id: str, request: Request, diff --git a/frontend/src/App.js b/frontend/src/App.js index e30ab80..5a561b9 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 0469582..a87158b 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -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() { ))} + + {shows.length > 0 && ( +
+

Series

+
+
+ Title + Year + Episodes + Actions +
+ {shows.map((s) => ( +
+
+ + {s.title} +
+ {s.year} + {s.season_count}s · {s.episode_count}e + +
+ ))} +
+
+ )} ); diff --git a/frontend/src/pages/AdminShowEpisodes.jsx b/frontend/src/pages/AdminShowEpisodes.jsx new file mode 100644 index 0000000..282fe24 --- /dev/null +++ b/frontend/src/pages/AdminShowEpisodes.jsx @@ -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 HLS {s}; + }; + + if (!show) return
Loading…
; + + const allSelected = visible.length > 0 && visible.every((e) => selected.has(e.id)); + + return ( +
+
+ + Back to Admin + + Season Packs +

{show.title}

+

{episodes.length} episodes across {seasons.length} season(s). Select any to bulk transcode, hide, or delete.

+ + {/* Season filter chips */} +
+ View + + {seasons.map((n) => ( +
+ + +
+ ))} +
+ + {/* Table header */} +
+
+ + S·E + Title + Source + HLS + State +
+ {visible.length === 0 &&
No episodes.
} + {visible.map((ep) => ( +
+ + S{ep.season_number}·E{ep.episode_number} +

{ep.title || `Episode ${ep.episode_number}`}

+ {ep.storage_type} + {statusChip(ep)} + {ep.hidden ? hidden : live} +
+ ))} +
+
+ + {/* Sticky bulk action bar */} + {selected.size > 0 && ( +
+ {selected.size} selected +
+ + + + + + +
+
+ )} +
+ ); +} diff --git a/memory/PRD.md b/memory/PRD.md index 6885b33..c4ef622 100644 --- a/memory/PRD.md +++ b/memory/PRD.md @@ -60,6 +60,17 @@ - **Crash recovery**: stuck `running` jobs are reset to `failed` on backend restart - 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) - **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