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
+8 -4
View File
@@ -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
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):
"""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,