mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 05:23:45 -05:00
auto-commit for 296e06eb-544f-4710-845e-c5a611b15f0e
This commit is contained in:
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user