mirror of
https://github.com/myronblair/kino-app
synced 2026-07-27 21:18:43 -05:00
auto-commit for 296e06eb-544f-4710-845e-c5a611b15f0e
This commit is contained in:
+8
-4
@@ -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
|
||||
|
||||
+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):
|
||||
"""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,
|
||||
|
||||
Reference in New Issue
Block a user