Codec-aware transcode routing, two-stage queue, request candidate picker, admin reorg

- Requests approval now shows a Radarr/Sonarr candidate picker instead of grabbing the first search result (fixes wrong-title/wrong-year matches)
- approve_request reuses an existing Radarr/Sonarr entry instead of erroring when content is already in the library
- New codec-check queue (step 1) probes source codecs and skips transcoding entirely for already browser-native files; only non-native content reaches the transcode queue (step 2)
- Fixes ffmpeg hard-failing on non-H.264 sources via the h264_mp4toannexb bitstream filter (root cause of the fix-audio-all failures)
- Added Codec Check Worker / Transcode Worker to the services health panel with restart
- Reorganized the admin page into Pipeline/Utilities dropdowns; added the missing Sonarr Import link
- Bumped VERSION 1.0.0 -> 1.1.0
This commit is contained in:
root
2026-07-30 20:41:11 -05:00
parent 671074764e
commit b28a49e8fa
13 changed files with 784 additions and 50 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.0 1.1.0
+53
View File
@@ -0,0 +1,53 @@
import asyncio
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path
from motor.motor_asyncio import AsyncIOMotorClient
from transcode import classify_source
async def main():
client = AsyncIOMotorClient(os.environ["MONGO_URL"])
db = client[os.environ.get("DB_NAME", "kino")]
videos_dir = Path(os.environ.get("MEDIA_ROOT", "/media")) / "videos"
cancel_res = await db.transcode_queue.update_many(
{"status": "pending"},
{"$set": {"status": "cancelled", "finished_at": datetime.now(timezone.utc).isoformat()}},
)
print(f"cancelled stale pending jobs: {cancel_res.modified_count}")
stats = {"native": 0, "audio": 0, "abr": 0, "no_source": 0}
for coll_name, content_type in (("movies", "movie"), ("episodes", "episode")):
coll = db[coll_name]
async for doc in coll.find({"hls_status": {"$ne": "done"}}, {"_id": 0}):
storage_type = doc.get("storage_type")
storage_path = doc.get("storage_path")
if storage_type not in ("local", "radarr", "sonarr", "scan") or not storage_path:
stats["no_source"] += 1
continue
source = Path(storage_path) if storage_type in ("radarr", "sonarr", "scan") else videos_dir / storage_path
classification = await classify_source(source)
if classification == "native":
await coll.update_one({"id": doc["id"]}, {"$set": {"hls_status": "not_needed"}})
stats["native"] += 1
continue
quality = "abr" if classification == "abr" else "audio"
stats[quality] += 1
job = {
"id": str(uuid.uuid4()), "movie_id": doc["id"], "movie_title": doc.get("title", ""),
"content_type": content_type, "quality": quality, "status": "pending", "error": "",
"triggered_by": "backfill", "progress": 0.0, "superseded_by": None,
"created_at": datetime.now(timezone.utc).isoformat(), "started_at": None, "finished_at": None,
}
await db.transcode_queue.insert_one(job)
await coll.update_one({"id": doc["id"]}, {"$set": {"hls_status": "pending"}})
print(stats)
asyncio.run(main())
+22
View File
@@ -193,6 +193,8 @@ class RequestUpdate(BaseModel):
class RequestApprove(BaseModel): class RequestApprove(BaseModel):
content_type: str # "movie" | "tv" content_type: str # "movie" | "tv"
tmdb_id: Optional[int] = None # movie: explicit pick from /requests/{id}/candidates
tvdb_id: Optional[int] = None # tv: explicit pick from /requests/{id}/candidates
class MovieRequest(BaseModel): class MovieRequest(BaseModel):
@@ -380,6 +382,26 @@ class TranscodeJob(BaseModel):
finished_at: Optional[str] = None finished_at: Optional[str] = None
class CodecCheckJob(BaseModel):
"""Step 1 of the pipeline, ahead of TranscodeJob (step 2): probes the source file's actual
video/audio codecs and decides whether it needs a transcode at all. 'native' sources skip
straight to the library with no transcode job ever created; 'audio'/'abr' sources get
handed off to a TranscodeJob."""
model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
movie_id: str # generic content id (movie_id OR episode_id, depending on content_type)
movie_title: str = ""
content_type: str = "movie" # movie | episode
status: str = "pending" # pending | running | done | failed | cancelled | superseded
classification: Optional[str] = None # native | audio | abr, set once done
error: str = ""
triggered_by: str = "manual"
superseded_by: Optional[str] = None
created_at: str = Field(default_factory=_now_iso)
started_at: Optional[str] = None
finished_at: Optional[str] = None
class QueuePauseToggle(BaseModel): class QueuePauseToggle(BaseModel):
paused: bool paused: bool
+12 -1
View File
@@ -54,7 +54,10 @@ def search_movie(base_url: str, api_key: str, term: str) -> List[Dict]:
def add_movie(base_url: str, api_key: str, tmdb_id: int, title: str, year: Optional[int]) -> Dict: def add_movie(base_url: str, api_key: str, tmdb_id: int, title: str, year: Optional[int]) -> Dict:
"""Adds a movie by tmdb_id with monitoring + an immediate search enabled, using the first """Adds a movie by tmdb_id with monitoring + an immediate search enabled, using the first
configured quality profile and root folder — same defaults Radarr's own UI would pre-fill.""" configured quality profile and root folder — same defaults Radarr's own UI would pre-fill.
If the movie is already in Radarr's library, Radarr rejects the add with a 400
MovieExistsValidator — in that case, reuse the existing movie and just kick off a search
instead of surfacing the add as a failure."""
base_url = base_url.rstrip("/") base_url = base_url.rstrip("/")
h = _h(api_key) h = _h(api_key)
profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json() profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json()
@@ -67,6 +70,14 @@ def add_movie(base_url: str, api_key: str, tmdb_id: int, title: str, year: Optio
"monitored": True, "addOptions": {"searchForMovie": True}, "monitored": True, "addOptions": {"searchForMovie": True},
} }
r = requests.post(f"{base_url}/api/v3/movie", json=payload, headers=h, timeout=20) r = requests.post(f"{base_url}/api/v3/movie", json=payload, headers=h, timeout=20)
if r.status_code == 400 and any(e.get("errorCode") == "MovieExistsValidator" for e in (r.json() or [])):
existing = requests.get(f"{base_url}/api/v3/movie", params={"tmdbId": tmdb_id}, headers=h, timeout=15).json()
if not existing:
raise RuntimeError("Radarr reported movie already exists but it could not be found")
movie = existing[0]
requests.put(f"{base_url}/api/v3/movie/{movie['id']}", json={**movie, "monitored": True}, headers=h, timeout=15)
requests.post(f"{base_url}/api/v3/command", json={"name": "MoviesSearch", "movieIds": [movie["id"]]}, headers=h, timeout=15)
return {"radarr_id": movie["id"], "title": movie.get("title"), "year": movie.get("year")}
r.raise_for_status() r.raise_for_status()
added = r.json() added = r.json()
return {"radarr_id": added["id"], "title": added.get("title"), "year": added.get("year")} return {"radarr_id": added["id"], "title": added.get("title"), "year": added.get("year")}
+286 -16
View File
@@ -24,7 +24,7 @@ from models import (
RequestCreate, RequestUpdate, RequestApprove, MovieRequest, RequestCreate, RequestUpdate, RequestApprove, MovieRequest,
AppSettings, AppSettingsPublic, AppSettings, AppSettingsPublic,
TMDBSearchResult, RadarrMovieDTO, TMDBSearchResult, RadarrMovieDTO,
TranscodeJob, QueuePauseToggle, TranscodeJob, CodecCheckJob, QueuePauseToggle,
Show, ShowCreate, ShowUpdate, Episode, EpisodeCreate, EpisodeProgressUpsert, Show, ShowCreate, ShowUpdate, Episode, EpisodeCreate, EpisodeProgressUpsert,
SonarrSeriesDTO, TraktDeviceCodeResponse, TraktStatus, OpenSubsResult, SonarrSeriesDTO, TraktDeviceCodeResponse, TraktStatus, OpenSubsResult,
Track, TrackUpdate, Track, TrackUpdate,
@@ -42,7 +42,7 @@ import itunes as itunes_client
import acoustid as acoustid_client import acoustid as acoustid_client
import trakt as trakt_client import trakt as trakt_client
import opensubtitles as opensubs_client import opensubtitles as opensubs_client
from transcode import transcode_quick, transcode_abr, transcode_audio_fix, srt_to_vtt from transcode import transcode_quick, transcode_abr, transcode_audio_fix, srt_to_vtt, classify_source
ROOT_DIR = Path(__file__).parent ROOT_DIR = Path(__file__).parent
@@ -185,8 +185,14 @@ async def on_startup():
{"$set": {"status": "failed", "error": "Backend restarted while running"}}, {"$set": {"status": "failed", "error": "Backend restarted while running"}},
) )
await db.transcode_queue.create_index([("status", 1), ("created_at", 1)]) await db.transcode_queue.create_index([("status", 1), ("created_at", 1)])
await db.codec_check_queue.update_many(
{"status": "running"},
{"$set": {"status": "failed", "error": "Backend restarted while running"}},
)
await db.codec_check_queue.create_index([("status", 1), ("created_at", 1)])
# Start the background transcode worker # Start the background workers: codec check (step 1) feeds the transcode queue (step 2)
asyncio.create_task(_codec_check_worker())
asyncio.create_task(_transcode_worker()) asyncio.create_task(_transcode_worker())
@@ -477,11 +483,11 @@ async def upload_video(
doc = movie.model_dump() doc = movie.model_dump()
await db.movies.insert_one(doc) await db.movies.insert_one(doc)
# Auto-enqueue transcode if enabled # Auto-enqueue codec check (step 1) if enabled
s = await get_settings() s = await get_settings()
auto = s.get("auto_transcode", "off") auto = s.get("auto_transcode", "off")
if auto in ("quick", "abr"): if auto in ("quick", "abr"):
await _enqueue_transcode(doc["id"], auto, triggered_by="auto") await _enqueue_codec_check(doc["id"], content_type="movie", triggered_by="auto")
return _strip(doc) return _strip(doc)
@@ -613,6 +619,93 @@ async def _process_job(job: dict):
) )
def _source_path_for(doc: dict) -> Optional[Path]:
"""Resolve a movie/episode doc's on-disk source path, or None if it has no local file."""
storage_type = doc.get("storage_type")
storage_path = doc.get("storage_path")
if not storage_path:
return None
if storage_type in ("radarr", "sonarr", "scan"):
return Path(storage_path)
if storage_type == "local":
return VIDEOS_DIR / storage_path
return None
async def _enqueue_codec_check(content_id: str, content_type: str = "movie", triggered_by: str = "auto") -> dict:
"""Step 1: queue a codec probe for newly-imported content. The worker below decides,
per file, whether it needs a transcode at all (see classify_source) — 'native' sources go
straight to the library, everything else gets handed to _enqueue_transcode (step 2)."""
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.codec_check_queue.find_one(
{"movie_id": content_id, "status": {"$in": ["pending", "running"]}}, {"_id": 0},
)
if existing:
return existing
job = CodecCheckJob(
movie_id=content_id, movie_title=title, content_type=content_type, triggered_by=triggered_by,
).model_dump()
await db.codec_check_queue.insert_one(job)
return job
async def _process_codec_check(job: dict):
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.codec_check_queue.update_one(
{"id": job["id"]},
{"$set": {"status": "failed", "error": f"{content_type} not found", "finished_at": datetime.now(timezone.utc).isoformat()}},
)
return
source = _source_path_for(doc)
if source is None:
await db.codec_check_queue.update_one(
{"id": job["id"]},
{"$set": {"status": "failed", "error": "Not a local file", "finished_at": datetime.now(timezone.utc).isoformat()}},
)
return
classification = await classify_source(source)
await db.codec_check_queue.update_one(
{"id": job["id"]},
{"$set": {"status": "done", "classification": classification, "finished_at": datetime.now(timezone.utc).isoformat()}},
)
if classification == "native":
await coll.update_one({"id": job["movie_id"]}, {"$set": {"hls_status": "not_needed"}})
else:
quality = "abr" if classification == "abr" else "audio"
await _enqueue_transcode(job["movie_id"], quality, triggered_by="codec_check", content_type=content_type)
async def _codec_check_worker():
"""Single FIFO worker for step 1, mirroring the transcode worker below."""
logger.info("Codec check worker started")
while True:
try:
settings = await get_settings()
if settings.get("codec_check_paused", False):
await asyncio.sleep(15)
continue
job = await db.codec_check_queue.find_one_and_update(
{"status": "pending"},
{"$set": {"status": "running", "started_at": datetime.now(timezone.utc).isoformat()}},
sort=[("created_at", 1)],
projection={"_id": 0},
return_document=True,
)
if not job:
await asyncio.sleep(8)
continue
await _process_codec_check(job)
except Exception:
logger.exception("Codec check worker error")
await asyncio.sleep(10)
async def _transcode_worker(): async def _transcode_worker():
"""Single FIFO worker. Polls for pending jobs and runs them serially.""" """Single FIFO worker. Polls for pending jobs and runs them serially."""
logger.info("Transcode worker started") logger.info("Transcode worker started")
@@ -640,9 +733,15 @@ async def _transcode_worker():
async def _enqueue_transcode(content_id: str, quality: str, triggered_by: str = "manual", content_type: str = "movie") -> 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. Works for movies + episodes.""" """Add a job to the queue. Returns the job document. Works for movies + episodes.
Before queuing, probes the actual source codec and lets that override the requested
quality: already-browser-native sources (H.264 + AAC/MP3) skip transcoding entirely
(hls_status becomes "not_needed" and playback falls back to the raw-file stream endpoint,
which already handles this case), and non-H.264 sources are forced to "abr" regardless of
what was requested — "quick"/"audio" apply a bitstream filter that assumes H.264 input and
crashes ffmpeg outright otherwise (this is what caused the recent fix-audio-all failures)."""
coll = db.movies if content_type == "movie" else db.episodes coll = db.movies if content_type == "movie" else db.episodes
doc = await coll.find_one({"id": content_id}, {"_id": 0, "title": 1}) doc = await coll.find_one({"id": content_id}, {"_id": 0})
title = doc.get("title", "") if doc else "" title = doc.get("title", "") if doc else ""
existing = await db.transcode_queue.find_one( existing = await db.transcode_queue.find_one(
{"movie_id": content_id, "status": {"$in": ["pending", "running"]}}, {"movie_id": content_id, "status": {"$in": ["pending", "running"]}},
@@ -650,6 +749,24 @@ async def _enqueue_transcode(content_id: str, quality: str, triggered_by: str =
) )
if existing: if existing:
return existing return existing
source = None
if doc and doc.get("storage_path"):
if doc.get("storage_type") in ("radarr", "sonarr", "scan"):
source = Path(doc["storage_path"])
elif doc.get("storage_type") == "local":
source = VIDEOS_DIR / doc["storage_path"]
if source is not None:
classification = await classify_source(source)
if classification == "native":
await coll.update_one({"id": content_id}, {"$set": {"hls_status": "not_needed"}})
return {"skipped": True, "reason": "native", "movie_id": content_id, "movie_title": title}
if classification == "abr":
quality = "abr"
elif classification == "audio" and quality == "quick":
quality = "audio"
job = TranscodeJob( job = TranscodeJob(
movie_id=content_id, movie_title=title, quality=quality, movie_id=content_id, movie_title=title, quality=quality,
triggered_by=triggered_by, content_type=content_type, triggered_by=triggered_by, content_type=content_type,
@@ -672,9 +789,83 @@ async def trigger_transcode(movie_id: str, payload: Optional[dict] = None, user:
if movie.get("hls_status") in ("running", "pending"): if movie.get("hls_status") in ("running", "pending"):
raise HTTPException(status_code=409, detail="Already transcoding or queued") raise HTTPException(status_code=409, detail="Already transcoding or queued")
job = await _enqueue_transcode(movie_id, quality, triggered_by="manual") job = await _enqueue_transcode(movie_id, quality, triggered_by="manual")
if job.get("skipped"):
return {"ok": True, "status": "not_needed", "quality": quality, "job_id": None}
return {"ok": True, "status": "pending", "quality": quality, "job_id": job["id"]} return {"ok": True, "status": "pending", "quality": quality, "job_id": job["id"]}
# ============ Codec Check Queue endpoints (step 1, ahead of the Transcode Queue) ============
@api.get("/codec-check/queue", response_model=List[CodecCheckJob])
async def list_codec_check_queue(status: Optional[str] = None, user: dict = Depends(require_admin)):
q: dict = {}
if status:
q["status"] = {"$in": status.split(",")}
rows = await db.codec_check_queue.find(q, {"_id": 0}).sort("created_at", -1).to_list(500)
return rows
@api.get("/codec-check/queue/running", response_model=Optional[CodecCheckJob])
async def get_running_codec_check_job(user: dict = Depends(require_admin)):
return await db.codec_check_queue.find_one({"status": "running"}, {"_id": 0})
@api.get("/codec-check/queue/stats")
async def codec_check_queue_stats(user: dict = Depends(require_admin)):
pipeline = [{"$group": {"_id": "$status", "n": {"$sum": 1}}}]
out = {"pending": 0, "running": 0, "done": 0, "failed": 0, "cancelled": 0, "superseded": 0}
async for row in db.codec_check_queue.aggregate(pipeline):
out[row["_id"]] = row["n"]
s = await get_settings()
out["paused"] = bool(s.get("codec_check_paused", False))
return out
@api.delete("/codec-check/queue/{job_id}")
async def cancel_codec_check_job(job_id: str, user: dict = Depends(require_admin)):
job = await db.codec_check_queue.find_one({"id": job_id}, {"_id": 0})
if not job: raise HTTPException(status_code=404, detail="Job not found")
if job["status"] == "running":
raise HTTPException(status_code=400, detail="Cannot cancel a running job")
await db.codec_check_queue.update_one({"id": job_id}, {"$set": {"status": "cancelled", "finished_at": datetime.now(timezone.utc).isoformat()}})
return {"ok": True}
@api.post("/codec-check/queue/{job_id}/retry")
async def retry_codec_check_job(job_id: str, user: dict = Depends(require_admin)):
job = await db.codec_check_queue.find_one({"id": job_id}, {"_id": 0})
if not job: raise HTTPException(status_code=404, detail="Job not found")
if job["status"] not in ("failed", "cancelled"):
raise HTTPException(status_code=400, detail="Only failed/cancelled jobs can be retried")
new_job = await _enqueue_codec_check(job["movie_id"], content_type=job.get("content_type", "movie"), triggered_by=job.get("triggered_by", "manual"))
if new_job["id"] != job["id"]:
await db.codec_check_queue.update_one({"id": job["id"]}, {"$set": {"status": "superseded", "superseded_by": new_job["id"]}})
return {"ok": True, "job_id": new_job["id"]}
@api.post("/codec-check/queue/retry-failed")
async def retry_all_failed_codec_check(user: dict = Depends(require_admin)):
jobs = await db.codec_check_queue.find({"status": {"$in": ["failed", "cancelled"]}}, {"_id": 0}).to_list(None)
retried = 0
for job in jobs:
new_job = await _enqueue_codec_check(job["movie_id"], content_type=job.get("content_type", "movie"), triggered_by=job.get("triggered_by", "manual"))
if new_job["id"] != job["id"]:
await db.codec_check_queue.update_one({"id": job["id"]}, {"$set": {"status": "superseded", "superseded_by": new_job["id"]}})
retried += 1
return {"ok": True, "retried": retried}
@api.post("/codec-check/queue/clear")
async def clear_finished_codec_check(user: dict = Depends(require_admin)):
res = await db.codec_check_queue.delete_many({"status": {"$in": ["done", "failed", "cancelled", "superseded"]}})
return {"ok": True, "deleted": res.deleted_count}
@api.post("/codec-check/queue/pause")
async def pause_codec_check_queue(payload: QueuePauseToggle, user: dict = Depends(require_admin)):
await db.settings.update_one({"id": "app"}, {"$set": {"codec_check_paused": payload.paused}}, upsert=True)
return {"ok": True, "paused": payload.paused}
# ============ Transcode Queue endpoints ============ # ============ Transcode Queue endpoints ============
@api.get("/transcode/queue", response_model=List[TranscodeJob]) @api.get("/transcode/queue", response_model=List[TranscodeJob])
async def list_queue(status: Optional[str] = None, user: dict = Depends(require_admin)): async def list_queue(status: Optional[str] = None, user: dict = Depends(require_admin)):
@@ -726,6 +917,10 @@ async def retry_job(job_id: str, user: dict = Depends(require_admin)):
if job["status"] not in ("failed", "cancelled"): if job["status"] not in ("failed", "cancelled"):
raise HTTPException(status_code=400, detail="Only failed/cancelled jobs can be retried") raise HTTPException(status_code=400, detail="Only failed/cancelled jobs can be retried")
new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"), content_type=job.get("content_type", "movie")) new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"), content_type=job.get("content_type", "movie"))
if new_job.get("skipped"):
# Turned out to be browser-native on re-check — nothing to queue, just close out the old job.
await db.transcode_queue.update_one({"id": job["id"]}, {"$set": {"status": "superseded", "superseded_by": None}})
return {"ok": True, "job_id": None, "status": "not_needed"}
if new_job["id"] != job["id"]: if new_job["id"] != job["id"]:
# Keep the original row for the audit trail — mark it superseded rather than deleting, # Keep the original row for the audit trail — mark it superseded rather than deleting,
# so cancelled/failed history never silently disappears from the list. # so cancelled/failed history never silently disappears from the list.
@@ -739,7 +934,9 @@ async def retry_all_failed(user: dict = Depends(require_admin)):
retried = 0 retried = 0
for job in jobs: for job in jobs:
new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"), content_type=job.get("content_type", "movie")) new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"), content_type=job.get("content_type", "movie"))
if new_job["id"] != job["id"]: if new_job.get("skipped"):
await db.transcode_queue.update_one({"id": job["id"]}, {"$set": {"status": "superseded", "superseded_by": None}})
elif new_job["id"] != job["id"]:
await db.transcode_queue.update_one({"id": job["id"]}, {"$set": {"status": "superseded", "superseded_by": new_job["id"]}}) await db.transcode_queue.update_one({"id": job["id"]}, {"$set": {"status": "superseded", "superseded_by": new_job["id"]}})
retried += 1 retried += 1
return {"ok": True, "retried": retried} return {"ok": True, "retried": retried}
@@ -982,11 +1179,39 @@ async def update_request(request_id: str, payload: RequestUpdate, user: dict = D
return res return res
@api.get("/requests/{request_id}/candidates")
async def request_candidates(request_id: str, content_type: str, user: dict = Depends(require_admin)):
"""Returns Radarr/Sonarr's raw title-search results so an admin can see every match
(title, year, poster) and pick the right one before anything gets added/grabbed —
instead of approve blindly taking whatever came back first."""
if content_type not in ("movie", "tv"):
raise HTTPException(status_code=400, detail="content_type must be movie or tv")
req = await db.requests.find_one({"id": request_id}, {"_id": 0})
if not req: raise HTTPException(status_code=404, detail="Request not found")
s = await get_settings()
try:
if content_type == "movie":
if not s.get("radarr_url") or not s.get("radarr_api_key"):
raise HTTPException(status_code=400, detail="Radarr not configured")
return await asyncio.to_thread(radarr_client.search_movie, s["radarr_url"], s["radarr_api_key"], req["title"])
else:
if not s.get("sonarr_url") or not s.get("sonarr_api_key"):
raise HTTPException(status_code=400, detail="Sonarr not configured")
return await asyncio.to_thread(sonarr_client.search_series, s["sonarr_url"], s["sonarr_api_key"], req["title"])
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"Lookup error: {e}")
@api.post("/requests/{request_id}/approve", response_model=MovieRequest) @api.post("/requests/{request_id}/approve", response_model=MovieRequest)
async def approve_request(request_id: str, payload: RequestApprove, user: dict = Depends(require_admin)): async def approve_request(request_id: str, payload: RequestApprove, user: dict = Depends(require_admin)):
"""Looks up the request's title in Radarr/Sonarr, adds the best match with an immediate """Looks up the request's title in Radarr/Sonarr and adds a match with an immediate
search enabled, and marks the request approved. Content actually lands via the normal search enabled, then marks the request approved. Content actually lands via the normal
Radarr/Sonarr download pipeline — this only kicks that off.""" Radarr/Sonarr download pipeline — this only kicks that off.
If payload.tmdb_id/tvdb_id is set (admin picked a specific result from
GET /requests/{id}/candidates), that exact candidate is used. Otherwise falls back to
the old best-effort match (year match, else first search result) for backward compat."""
if payload.content_type not in ("movie", "tv"): if payload.content_type not in ("movie", "tv"):
raise HTTPException(status_code=400, detail="content_type must be movie or tv") raise HTTPException(status_code=400, detail="content_type must be movie or tv")
# Atomically claim the request so two concurrent approve calls (double-click, two admins) # Atomically claim the request so two concurrent approve calls (double-click, two admins)
@@ -1009,6 +1234,11 @@ async def approve_request(request_id: str, payload: RequestApprove, user: dict =
candidates = await asyncio.to_thread(radarr_client.search_movie, s["radarr_url"], s["radarr_api_key"], req["title"]) candidates = await asyncio.to_thread(radarr_client.search_movie, s["radarr_url"], s["radarr_api_key"], req["title"])
except Exception as e: except Exception as e:
raise HTTPException(status_code=502, detail=f"Radarr lookup error: {e}") raise HTTPException(status_code=502, detail=f"Radarr lookup error: {e}")
if payload.tmdb_id is not None:
match = next((c for c in candidates if c.get("tmdb_id") == payload.tmdb_id), None)
if not match:
raise HTTPException(status_code=404, detail="Selected match no longer in Radarr's search results")
else:
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None) match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
if not match: if not match:
raise HTTPException(status_code=404, detail="No match found in Radarr") raise HTTPException(status_code=404, detail="No match found in Radarr")
@@ -1025,6 +1255,11 @@ async def approve_request(request_id: str, payload: RequestApprove, user: dict =
candidates = await asyncio.to_thread(sonarr_client.search_series, s["sonarr_url"], s["sonarr_api_key"], req["title"]) candidates = await asyncio.to_thread(sonarr_client.search_series, s["sonarr_url"], s["sonarr_api_key"], req["title"])
except Exception as e: except Exception as e:
raise HTTPException(status_code=502, detail=f"Sonarr lookup error: {e}") raise HTTPException(status_code=502, detail=f"Sonarr lookup error: {e}")
if payload.tvdb_id is not None:
match = next((c for c in candidates if c.get("tvdb_id") == payload.tvdb_id), None)
if not match:
raise HTTPException(status_code=404, detail="Selected match no longer in Sonarr's search results")
else:
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None) match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
if not match: if not match:
raise HTTPException(status_code=404, detail="No match found in Sonarr") raise HTTPException(status_code=404, detail="No match found in Sonarr")
@@ -1190,7 +1425,7 @@ async def radarr_import(payload: dict, user: dict = Depends(require_admin)):
).model_dump() ).model_dump()
await db.movies.insert_one(movie) await db.movies.insert_one(movie)
if auto in ("quick", "abr"): if auto in ("quick", "abr"):
await _enqueue_transcode(movie["id"], auto, triggered_by="auto") await _enqueue_codec_check(movie["id"], content_type="movie", triggered_by="auto")
# Auto-fetch English subtitle (fire and forget) # Auto-fetch English subtitle (fire and forget)
asyncio.create_task(_auto_fetch_subtitle("movie", movie["id"], movie.get("tmdb_id"))) asyncio.create_task(_auto_fetch_subtitle("movie", movie["id"], movie.get("tmdb_id")))
created += 1 created += 1
@@ -1323,6 +1558,8 @@ async def transcode_episode(episode_id: str, payload: Optional[dict] = None, use
if ep.get("hls_status") in ("running", "pending"): if ep.get("hls_status") in ("running", "pending"):
raise HTTPException(status_code=409, detail="Already transcoding or queued") raise HTTPException(status_code=409, detail="Already transcoding or queued")
job = await _enqueue_transcode(episode_id, quality, triggered_by="manual", content_type="episode") job = await _enqueue_transcode(episode_id, quality, triggered_by="manual", content_type="episode")
if job.get("skipped"):
return {"ok": True, "status": "not_needed", "job_id": None}
return {"ok": True, "job_id": job["id"]} return {"ok": True, "job_id": job["id"]}
@@ -1477,7 +1714,7 @@ async def sonarr_import(payload: dict, user: dict = Depends(require_admin)):
).model_dump() ).model_dump()
await db.episodes.insert_one(edoc); created_eps += 1 await db.episodes.insert_one(edoc); created_eps += 1
if auto in ("quick", "abr"): if auto in ("quick", "abr"):
await _enqueue_transcode(edoc["id"], auto, triggered_by="auto", content_type="episode") await _enqueue_codec_check(edoc["id"], content_type="episode", triggered_by="auto")
# Auto-fetch English subtitle for this episode (needs show's tmdb_id + season/episode numbers) # Auto-fetch English subtitle for this episode (needs show's tmdb_id + season/episode numbers)
show_doc = await db.shows.find_one({"id": show_id}, {"_id": 0, "tmdb_id": 1}) show_doc = await db.shows.find_one({"id": show_id}, {"_id": 0, "tmdb_id": 1})
if show_doc and show_doc.get("tmdb_id"): if show_doc and show_doc.get("tmdb_id"):
@@ -1544,7 +1781,7 @@ async def scan_import(payload: dict, user: dict = Depends(require_admin)):
).model_dump() ).model_dump()
await db.movies.insert_one(movie) await db.movies.insert_one(movie)
if auto in ("quick", "abr"): if auto in ("quick", "abr"):
await _enqueue_transcode(movie["id"], auto, triggered_by="auto") await _enqueue_codec_check(movie["id"], content_type="movie", triggered_by="auto")
created += 1 created += 1
elif kind == "tv": elif kind == "tv":
@@ -1570,7 +1807,7 @@ async def scan_import(payload: dict, user: dict = Depends(require_admin)):
).model_dump() ).model_dump()
await db.episodes.insert_one(edoc) await db.episodes.insert_one(edoc)
if auto in ("quick", "abr"): if auto in ("quick", "abr"):
await _enqueue_transcode(edoc["id"], auto, triggered_by="auto", content_type="episode") await _enqueue_codec_check(edoc["id"], content_type="episode", triggered_by="auto")
created += 1 created += 1
for show_id in set(shows_by_title.values()): for show_id in set(shows_by_title.values()):
seasons = await db.episodes.distinct("season_number", {"show_id": show_id}) seasons = await db.episodes.distinct("season_number", {"show_id": show_id})
@@ -2067,13 +2304,46 @@ async def _auto_fetch_subtitle(content_type: str, content_id: str, tmdb_id: Opti
# ============ SERVICES (admin) ============ # ============ SERVICES (admin) ============
WORKER_SERVICES = [
# (key, label, queue collection, stall threshold in seconds)
("codec_check_worker", "Codec Check Worker", "codec_check_queue", 600), # ffprobe is fast — 10min stuck means hung
("transcode_worker", "Transcode Worker", "transcode_queue", 4 * 60 * 60), # abr encodes can legitimately run hours
]
async def _worker_service_statuses() -> List[dict]:
out = []
for key, label, coll_name, stall_seconds in WORKER_SERVICES:
running_job = await db[coll_name].find_one({"status": "running"}, {"_id": 0, "started_at": 1})
stalled = False
if running_job and running_job.get("started_at"):
try:
started = datetime.fromisoformat(running_job["started_at"])
if (datetime.now(timezone.utc) - started).total_seconds() > stall_seconds:
stalled = True
except ValueError:
pass
out.append({"key": key, "label": label, "running": not stalled})
return out
@api.get("/services") @api.get("/services")
async def services_status(user: dict = Depends(require_admin)): async def services_status(user: dict = Depends(require_admin)):
return await asyncio.to_thread(services_client.list_service_status) docker_services = await asyncio.to_thread(services_client.list_service_status)
return docker_services + await _worker_service_statuses()
@api.post("/services/{key}/restart") @api.post("/services/{key}/restart")
async def services_restart(key: str, user: dict = Depends(require_admin)): async def services_restart(key: str, user: dict = Depends(require_admin)):
if key in ("codec_check_worker", "transcode_worker"):
# Both workers live in the backend process — restarting one restarts the whole
# container (and briefly the API with it). The stuck "running" job gets marked
# failed automatically by the startup crash-recovery logic, so it won't just hang
# again immediately; retry it from the relevant queue page afterward.
ok = await asyncio.to_thread(services_client.restart_backend)
if not ok:
raise HTTPException(status_code=500, detail="Could not restart backend")
return {"ok": True}
ok = await asyncio.to_thread(services_client.restart_service, key) ok = await asyncio.to_thread(services_client.restart_service, key)
if not ok: if not ok:
raise HTTPException(status_code=404, detail="Unknown service") raise HTTPException(status_code=404, detail="Unknown service")
+15
View File
@@ -44,3 +44,18 @@ def restart_service(key: str) -> bool:
c = client.containers.get(svc["container"]) c = client.containers.get(svc["container"])
c.restart(timeout=15) c.restart(timeout=15)
return True return True
# The codec-check and transcode workers are asyncio background tasks inside the backend
# process itself, not separate containers — there's no way to bounce just one of them, so
# "restart" for either really means restarting the whole streamhoard-backend container. That
# briefly takes the API down too, not just the stalled queue; the caller (server.py) surfaces
# both worker "services" as needing this same restart target.
BACKEND_CONTAINER = "streamhoard-backend"
def restart_backend() -> bool:
client = _get_client()
c = client.containers.get(BACKEND_CONTAINER)
c.restart(timeout=15)
return True
+15 -1
View File
@@ -28,7 +28,11 @@ def search_series(base_url: str, api_key: str, term: str) -> List[Dict]:
def add_series(base_url: str, api_key: str, tvdb_id: int, title: str, year) -> Dict: def add_series(base_url: str, api_key: str, tvdb_id: int, title: str, year) -> Dict:
"""Adds a series by tvdb_id with monitoring + an immediate missing-episode search enabled.""" """Adds a series by tvdb_id with monitoring + an immediate missing-episode search enabled.
If the series is already in Sonarr's library (common for older/library titles carried over
from a past migration), Sonarr rejects the add with a 400 SeriesExistsValidator in that
case, reuse the existing series and just kick off a missing-episode search instead of
surfacing the add as a failure."""
base_url = base_url.rstrip("/") base_url = base_url.rstrip("/")
h = _h(api_key) h = _h(api_key)
profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json() profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json()
@@ -42,6 +46,16 @@ def add_series(base_url: str, api_key: str, tvdb_id: int, title: str, year) -> D
"addOptions": {"searchForMissingEpisodes": True, "monitor": "all"}, "addOptions": {"searchForMissingEpisodes": True, "monitor": "all"},
} }
r = requests.post(f"{base_url}/api/v3/series", json=payload, headers=h, timeout=20) r = requests.post(f"{base_url}/api/v3/series", json=payload, headers=h, timeout=20)
if r.status_code == 400 and any(e.get("errorCode") == "SeriesExistsValidator" for e in (r.json() or [])):
existing = requests.get(f"{base_url}/api/v3/series", params={"tvdbId": tvdb_id}, headers=h, timeout=15).json()
if not existing:
raise RuntimeError("Sonarr reported series already exists but it could not be found")
series = existing[0]
requests.post(f"{base_url}/api/v3/series/editor", json={
"seriesIds": [series["id"]], "monitored": True,
}, headers=h, timeout=15)
requests.post(f"{base_url}/api/v3/command", json={"name": "SeriesSearch", "seriesId": series["id"]}, headers=h, timeout=15)
return {"sonarr_id": series["id"], "title": series.get("title"), "year": series.get("year")}
r.raise_for_status() r.raise_for_status()
added = r.json() added = r.json()
return {"sonarr_id": added["id"], "title": added.get("title"), "year": added.get("year")} return {"sonarr_id": added["id"], "title": added.get("title"), "year": added.get("year")}
+47
View File
@@ -54,6 +54,53 @@ async def probe_duration(source: Path) -> float:
return 0.0 return 0.0
async def probe_codecs(source: Path) -> Tuple[str, str]:
"""Return (video_codec, audio_codec), lowercased, via ffprobe; ('', '') on failure."""
try:
proc = await asyncio.create_subprocess_exec(
"ffprobe", "-v", "error", "-show_entries", "stream=codec_type,codec_name",
"-of", "json", str(source),
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await proc.communicate()
if proc.returncode != 0:
return ("", "")
data = json.loads(stdout.decode("utf-8", errors="ignore") or "{}")
video_codec = audio_codec = ""
for s in data.get("streams") or []:
if s.get("codec_type") == "video" and not video_codec:
video_codec = (s.get("codec_name") or "").lower()
elif s.get("codec_type") == "audio" and not audio_codec:
audio_codec = (s.get("codec_name") or "").lower()
return (video_codec, audio_codec)
except Exception:
return ("", "")
BROWSER_NATIVE_AUDIO = {"aac", "mp3"}
async def classify_source(source: Path) -> str:
"""Decide the cheapest correct transcode path for a source file:
- 'native': H.264 video + AAC/MP3 audio browsers already play this file as-is (the
raw-file stream endpoint serves it directly), so no transcode is needed at all.
- 'audio': H.264 video but non-browser audio (AC3/DTS/EAC3/etc) cheap audio-only
re-encode, video stream-copied untouched.
- 'abr': anything else (HEVC/VP9/etc, or codec probing failed) needs a real video
re-encode. Also the safe fallback on probe failure: it's the only mode that doesn't
assume the source is already H.264 'audio'/'quick' apply the h264_mp4toannexb
bitstream filter unconditionally, which makes ffmpeg hard-fail on non-H.264 input.
"""
if not source.is_file():
return "abr"
video_codec, audio_codec = await probe_codecs(source)
if not video_codec or video_codec != "h264":
return "abr"
if audio_codec in BROWSER_NATIVE_AUDIO:
return "native"
return "audio"
# (height, video_bitrate, max_bitrate, buffer, audio_bitrate) # (height, video_bitrate, max_bitrate, buffer, audio_bitrate)
ALL_VARIANTS = [ ALL_VARIANTS = [
(1080, "5000k", "5500k", "7500k", "192k"), (1080, "5000k", "5500k", "7500k", "192k"),
+2
View File
@@ -19,6 +19,7 @@ import AdminUpload from "./pages/AdminUpload";
import Settings from "./pages/Settings"; import Settings from "./pages/Settings";
import RadarrImport from "./pages/RadarrImport"; import RadarrImport from "./pages/RadarrImport";
import TranscodeQueue from "./pages/TranscodeQueue"; import TranscodeQueue from "./pages/TranscodeQueue";
import CodecCheckQueue from "./pages/CodecCheckQueue";
import Shows from "./pages/Shows"; 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";
@@ -105,6 +106,7 @@ function App() {
<Route path="/admin/cleanup" element={<AdminGate><LibraryCleanup /></AdminGate>} /> <Route path="/admin/cleanup" element={<AdminGate><LibraryCleanup /></AdminGate>} />
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></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="/admin/codec-check" element={<AdminGate><CodecCheckQueue /></AdminGate>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Shell> </Shell>
+62 -23
View File
@@ -1,9 +1,67 @@
import { useEffect, useState } from "react"; import { useEffect, useState, useRef } from "react";
import api from "../lib/api"; import api from "../lib/api";
import { toast } from "sonner"; import { toast } from "sonner";
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw, FolderSearch, Wrench } from "lucide-react"; import {
Trash2, Star, Film, Settings as SettingsIcon, Download, FolderSearch, Wrench, ScanSearch,
Tv, ClipboardList, ChevronDown, Users as UsersIcon,
} from "lucide-react";
import { useNavigate, Link } from "react-router-dom"; import { useNavigate, Link } from "react-router-dom";
// Ordered to match the actual content pipeline: a request comes in, gets pulled into
// Radarr/Sonarr (or uploaded/scanned in directly), gets codec-checked, then transcoded if
// needed after that it's just in the library. Everything else is a standing utility, not a
// step content passes through, so it lives in its own group.
const PIPELINE_LINKS = [
{ to: "/requests", label: "Review Requests", icon: ClipboardList, testId: "admin-requests-link" },
{ to: "/admin/radarr", label: "Radarr Import", icon: Download, testId: "admin-radarr-link" },
{ to: "/admin/sonarr", label: "Sonarr Import", icon: Tv, testId: "admin-sonarr-link" },
{ to: "/admin/upload", label: "Upload Movie", icon: Film, testId: "admin-upload-link" },
{ to: "/admin/scan", label: "Library Scan", icon: FolderSearch, testId: "admin-scan-link" },
{ to: "/admin/codec-check", label: "Codec Check Queue", icon: ScanSearch, testId: "admin-codec-check-link" },
{ to: "/admin/queue", label: "Transcode Queue", icon: SettingsIcon, testId: "admin-queue-link" },
];
const UTILITY_LINKS = [
{ to: "/admin/cleanup", label: "Library Cleanup", icon: Wrench, testId: "admin-cleanup-link" },
{ to: "/admin/users", label: "Users", icon: UsersIcon, testId: "admin-users-link" },
];
function AdminMenu({ label, links }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10"
data-testid={`admin-menu-${label.toLowerCase()}`}
>
{label} <ChevronDown size={14} strokeWidth={1.5} className={`transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute left-0 top-full mt-1 min-w-[220px] bg-[#0F0F0F] border border-[#222] z-20 shadow-lg" data-testid={`admin-menu-${label.toLowerCase()}-panel`}>
{links.map(({ to, label: linkLabel, icon: Icon, testId }) => (
<Link
key={to} to={to} onClick={() => setOpen(false)}
className="flex items-center gap-2 px-4 py-3 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white hover:bg-white/5 border-b border-[#222] last:border-b-0"
data-testid={testId}
>
<Icon size={14} strokeWidth={1.5} /> {linkLabel}
</Link>
))}
</div>
)}
</div>
);
}
export default function Admin() { export default function Admin() {
const nav = useNavigate(); const nav = useNavigate();
const [movies, setMovies] = useState([]); const [movies, setMovies] = useState([]);
@@ -79,27 +137,8 @@ export default function Admin() {
<p className="text-[#8A8A8A] mt-4">Manage your library, requests, integrations.</p> <p className="text-[#8A8A8A] mt-4">Manage your library, requests, integrations.</p>
<div className="mt-10 flex flex-wrap gap-3"> <div className="mt-10 flex flex-wrap gap-3">
<Link to="/admin/upload" className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="admin-upload-link"> <AdminMenu label="Pipeline" links={PIPELINE_LINKS} />
<Film size={14} strokeWidth={1.5} /> Upload Movie <AdminMenu label="Utilities" links={UTILITY_LINKS} />
</Link>
<Link to="/admin/radarr" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-radarr-link">
<Download size={14} strokeWidth={1.5} /> Radarr Import
</Link>
<Link to="/admin/scan" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-scan-link">
<FolderSearch size={14} strokeWidth={1.5} /> Library Scan
</Link>
<Link to="/admin/cleanup" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-cleanup-link">
<Wrench size={14} strokeWidth={1.5} /> Library Cleanup
</Link>
<Link to="/admin/queue" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-queue-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Queue
</Link>
<Link to="/admin/users" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-users-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Users
</Link>
<Link to="/requests" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10">
Review Requests
</Link>
</div> </div>
<div className="mt-12 border border-[#222]"> <div className="mt-12 border border-[#222]">
+173
View File
@@ -0,0 +1,173 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { RefreshCcw, X, RotateCcw, Pause, Play as PlayIcon, Trash2 } from "lucide-react";
const STATUS_COLORS = {
pending: "text-[#fcd34d]",
running: "text-[#fcd34d]",
done: "text-[#86efac]",
failed: "text-[#fca5a5]",
cancelled: "text-[#8A8A8A]",
superseded: "text-[#8A8A8A]",
};
const CLASSIFICATION_LABEL = {
native: "Native (no transcode)",
audio: "Audio fix needed",
abr: "Full re-encode needed",
};
export default function CodecCheckQueue() {
const [jobs, setJobs] = useState([]);
const [stats, setStats] = useState({ pending: 0, running: 0, done: 0, failed: 0, cancelled: 0, paused: false });
const [runningJob, setRunningJob] = useState(null);
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true);
try {
const [j, s] = await Promise.all([api.get("/codec-check/queue"), api.get("/codec-check/queue/stats")]);
setJobs(j.data);
setStats(s.data);
} finally { setLoading(false); }
};
const loadRunning = async () => {
try { const { data } = await api.get("/codec-check/queue/running"); setRunningJob(data); }
catch { /* ignore transient poll failures */ }
};
useEffect(() => { load(); loadRunning(); }, []);
useEffect(() => {
const pending = jobs.some((j) => j.status === "pending");
if (!runningJob && !pending) return;
const t = setInterval(() => { load(); loadRunning(); }, runningJob ? 2000 : 4000);
return () => clearInterval(t);
}, [jobs, runningJob]);
const listedJobs = jobs.filter((j) => j.status !== "running");
const cancel = async (id) => {
try { await api.delete(`/codec-check/queue/${id}`); toast.success("Job cancelled"); load(); }
catch (err) { toast.error(err.response?.data?.detail || "Could not cancel"); }
};
const retry = async (id) => {
try { await api.post(`/codec-check/queue/${id}/retry`); toast.success("Re-queued"); load(); }
catch (err) { toast.error(err.response?.data?.detail || "Could not retry"); }
};
const retryAllFailed = async () => {
try {
const { data } = await api.post("/codec-check/queue/retry-failed");
toast.success(`Re-queued ${data.retried} job(s)`);
load();
} catch (err) { toast.error(err.response?.data?.detail || "Could not retry"); }
};
const togglePause = async () => {
try {
const { data } = await api.post("/codec-check/queue/pause", { paused: !stats.paused });
toast.success(data.paused ? "Queue paused" : "Queue resumed");
load();
} catch { toast.error("Could not toggle pause"); }
};
const clearFinished = async () => {
if (!window.confirm("Remove all done/failed/cancelled jobs from the history?")) return;
try { const { data } = await api.post("/codec-check/queue/clear"); toast.success(`Cleared ${data.deleted}`); load(); }
catch { toast.error("Could not clear"); }
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="codec-check-queue-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Background · Step 1 of 2</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Codec Check Queue</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
Every newly imported movie/episode lands here first. A quick ffprobe decides whether it actually needs
transcoding at all: already-browser-native files (H.264 + AAC/MP3) skip straight to the library with
no transcode job ever created; everything else is handed off to the{" "}
<a href="/admin/queue" className="text-[#D9381E] hover:text-[#ED4B32]">Transcode Queue (step 2)</a>.
</p>
<div className="mt-8 grid grid-cols-2 sm:grid-cols-5 gap-3">
{["pending", "running", "done", "failed", "cancelled"].map((k) => (
<div key={k} className="border border-[#222] p-4" data-testid={`codec-stat-${k}`}>
<div className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{k}</div>
<div className={`mt-1 font-display text-3xl font-bold ${STATUS_COLORS[k]}`}>{stats[k] || 0}</div>
</div>
))}
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button onClick={load} disabled={loading} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="codec-queue-refresh">
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} /> Refresh
</button>
<button onClick={togglePause} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="codec-queue-pause-toggle">
{stats.paused ? <PlayIcon size={14} strokeWidth={1.5} /> : <Pause size={14} strokeWidth={1.5} />}
{stats.paused ? "Resume" : "Pause"}
</button>
<button onClick={retryAllFailed} disabled={!stats.failed && !stats.cancelled} className="flex items-center gap-2 border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="codec-queue-retry-all">
<RotateCcw size={14} strokeWidth={1.5} /> Retry All Failed
</button>
<button onClick={clearFinished} className="flex items-center gap-2 border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="codec-queue-clear">
<Trash2 size={14} strokeWidth={1.5} /> Clear Finished
</button>
</div>
{runningJob && (
<div className="mt-8 border border-[#D9381E]/40 bg-[#D9381E]/[0.04] p-5" data-testid="codec-now-processing">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.3em] text-[#D9381E]">
<span className="w-1.5 h-1.5 rounded-full bg-[#D9381E] animate-pulse" />
Checking Codec
</div>
<div className="mt-2 text-white text-lg truncate" title={runningJob.movie_title}>
{runningJob.movie_title || runningJob.movie_id.slice(0, 8)}
</div>
<div className="mt-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{runningJob.triggered_by}</div>
</div>
)}
<div className="mt-6 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-4">Title</span>
<span className="col-span-3">Result</span>
<span className="col-span-1">Trigger</span>
<span className="col-span-2">Status</span>
<span className="col-span-1">Created</span>
<span className="col-span-1 text-right">Actions</span>
</div>
{listedJobs.length === 0 ? (
<div className="p-8 text-center text-[#8A8A8A] text-sm" data-testid="codec-queue-empty">No jobs yet.</div>
) : listedJobs.map((j) => (
<div key={j.id} className="grid grid-cols-12 items-center px-5 py-3 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`codec-queue-row-${j.id}`}>
<div className="col-span-4 text-white truncate" title={j.movie_title}>{j.movie_title || j.movie_id.slice(0, 8)}</div>
<span className="col-span-3 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
{j.classification ? CLASSIFICATION_LABEL[j.classification] || j.classification : "—"}
</span>
<span className="col-span-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{j.triggered_by}</span>
<span className={`col-span-2 text-[10px] uppercase tracking-[0.3em] ${STATUS_COLORS[j.status]}`} data-testid={`codec-queue-status-${j.id}`}>
{j.status}
{j.error && j.status === "failed" && <span className="block text-[#fca5a5]/70 normal-case truncate" title={j.error}>{j.error.slice(0, 40)}</span>}
</span>
<span className="col-span-1 text-xs text-[#8A8A8A]">{new Date(j.created_at).toLocaleDateString()}</span>
<div className="col-span-1 flex justify-end gap-2">
{j.status === "pending" && (
<button onClick={() => cancel(j.id)} className="text-[#8A8A8A] hover:text-[#fca5a5]" data-testid={`codec-cancel-${j.id}`} aria-label="Cancel">
<X size={14} strokeWidth={1.5} />
</button>
)}
{(j.status === "failed" || j.status === "cancelled") && (
<button onClick={() => retry(j.id)} className="flex items-center gap-1 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-[#D9381E]" data-testid={`codec-retry-${j.id}`}>
<RotateCcw size={13} strokeWidth={1.5} /> Retry
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
+90 -4
View File
@@ -40,6 +40,80 @@ function LiveStatus({ requestId }) {
); );
} }
function CandidatePicker({ request, contentType, onClose, onPicked }) {
const [candidates, setCandidates] = useState(null);
const [picking, setPicking] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const { data } = await api.get(`/requests/${request.id}/candidates`, { params: { content_type: contentType } });
if (!cancelled) setCandidates(data);
} catch (err) {
if (!cancelled) {
toast.error(err.response?.data?.detail || "Could not search Radarr/Sonarr");
onClose();
}
}
})();
return () => { cancelled = true; };
}, [request.id, contentType]);
const pick = async (c) => {
setPicking(true);
try {
await onPicked(c);
} finally {
setPicking(false);
}
};
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-6" onClick={onClose}>
<div
className="bg-[#0F0F0F] border border-[#222] max-w-2xl w-full max-h-[80vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
data-testid="candidate-picker"
>
<div className="px-5 py-4 border-b border-[#222] flex items-center justify-between">
<h3 className="text-white font-display font-bold">
Pick the right match for "{request.title}"{request.year ? ` (${request.year})` : ""}
</h3>
<button onClick={onClose} className="text-[#8A8A8A] hover:text-white text-xs uppercase tracking-[0.2em]">Close</button>
</div>
{candidates === null ? (
<p className="text-[#8A8A8A] text-sm px-5 py-6">Searching</p>
) : candidates.length === 0 ? (
<p className="text-[#8A8A8A] text-sm px-5 py-6">No results found.</p>
) : (
<div>
{candidates.map((c) => (
<button
key={c.tmdb_id || c.tvdb_id}
onClick={() => pick(c)}
disabled={picking}
className="w-full flex items-start gap-4 px-5 py-4 border-b border-[#222] last:border-b-0 text-left hover:bg-[#1A1A1A] disabled:opacity-50"
data-testid={`candidate-${c.tmdb_id || c.tvdb_id}`}
>
{c.poster_url ? (
<img src={c.poster_url} alt="" className="w-12 h-18 object-cover shrink-0 bg-[#1A1A1A]" />
) : (
<div className="w-12 h-18 shrink-0 bg-[#1A1A1A]" />
)}
<div className="min-w-0">
<p className="text-white">{c.title} {c.year ? <span className="text-[#8A8A8A]">({c.year})</span> : null}</p>
{c.overview && <p className="text-xs text-[#8A8A8A] mt-1 line-clamp-2">{c.overview}</p>}
</div>
</button>
))}
</div>
)}
</div>
</div>
);
}
export default function Requests() { export default function Requests() {
const { user } = useAuth(); const { user } = useAuth();
const [mine, setMine] = useState([]); const [mine, setMine] = useState([]);
@@ -48,6 +122,7 @@ export default function Requests() {
const [year, setYear] = useState(""); const [year, setYear] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [approving, setApproving] = useState({}); const [approving, setApproving] = useState({});
const [picker, setPicker] = useState(null); // { request, contentType }
const load = async () => { const load = async () => {
const { data } = await api.get("/requests/mine"); const { data } = await api.get("/requests/mine");
@@ -59,11 +134,14 @@ export default function Requests() {
}; };
useEffect(() => { load(); }, [user?.is_admin]); useEffect(() => { load(); }, [user?.is_admin]);
const approve = async (id, contentType) => { const approveWithCandidate = async (request, contentType, candidate) => {
const id = request.id;
setApproving((p) => ({ ...p, [id]: true })); setApproving((p) => ({ ...p, [id]: true }));
try { try {
const { data } = await api.post(`/requests/${id}/approve`, { content_type: contentType }); const idField = contentType === "movie" ? { tmdb_id: candidate.tmdb_id } : { tvdb_id: candidate.tvdb_id };
const { data } = await api.post(`/requests/${id}/approve`, { content_type: contentType, ...idField });
toast.success(`Searching for "${data.matched_title || data.title}"`); toast.success(`Searching for "${data.matched_title || data.title}"`);
setPicker(null);
load(); load();
} catch (err) { } catch (err) {
toast.error(err.response?.data?.detail || "Could not approve request"); toast.error(err.response?.data?.detail || "Could not approve request");
@@ -189,10 +267,10 @@ export default function Requests() {
<div className="flex gap-2 shrink-0"> <div className="flex gap-2 shrink-0">
{r.status === "pending" && ( {r.status === "pending" && (
<> <>
<button onClick={() => approve(r.id, "movie")} disabled={approving[r.id]} <button onClick={() => setPicker({ request: r, contentType: "movie" })} disabled={approving[r.id]}
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50" className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50"
data-testid={`approve-movie-${r.id}`}>Approve (Movie)</button> data-testid={`approve-movie-${r.id}`}>Approve (Movie)</button>
<button onClick={() => approve(r.id, "tv")} disabled={approving[r.id]} <button onClick={() => setPicker({ request: r, contentType: "tv" })} disabled={approving[r.id]}
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50" className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50"
data-testid={`approve-tv-${r.id}`}>Approve (TV)</button> data-testid={`approve-tv-${r.id}`}>Approve (TV)</button>
</> </>
@@ -211,6 +289,14 @@ export default function Requests() {
</div> </div>
)} )}
</div> </div>
{picker && (
<CandidatePicker
request={picker.request}
contentType={picker.contentType}
onClose={() => setPicker(null)}
onPicked={(c) => approveWithCandidate(picker.request, picker.contentType, c)}
/>
)}
</div> </div>
); );
} }
+4 -2
View File
@@ -92,10 +92,12 @@ export default function TranscodeQueue() {
return ( return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="queue-page"> <div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="queue-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto"> <div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Background</span> <span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Background · Step 2 of 2</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Transcode Queue</h1> <h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Transcode Queue</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl"> <p className="text-[#8A8A8A] mt-4 max-w-2xl">
One job runs at a time, moderate CPU priority, capped threads so it can't starve the rest of the stack. Auto-transcode is currently:{" "} Only files the <a href="/admin/codec-check" className="text-[#D9381E] hover:text-[#ED4B32]">Codec Check Queue (step 1)</a> flagged
as needing work land here already browser-native files skip this queue entirely. One job runs at a time, moderate CPU priority,
capped threads so it can't starve the rest of the stack. Auto-transcode is currently:{" "}
<span className="text-white">{stats.auto_transcode === "off" ? "off" : `auto · ${stats.auto_transcode}`}</span> <span className="text-white">{stats.auto_transcode === "off" ? "off" : `auto · ${stats.auto_transcode}`}</span>
{" — "}<a href="/admin/settings" className="text-[#D9381E] hover:text-[#ED4B32]">change in Settings</a>. Failed and cancelled jobs never delete the movie retry re-queues the same file. {" — "}<a href="/admin/settings" className="text-[#D9381E] hover:text-[#ED4B32]">change in Settings</a>. Failed and cancelled jobs never delete the movie retry re-queues the same file.
</p> </p>