diff --git a/backend/server.py b/backend/server.py
index edf9d4d..986fd00 100644
--- a/backend/server.py
+++ b/backend/server.py
@@ -40,7 +40,7 @@ import library_cleanup
import musicbrainz as musicbrainz_client
import trakt as trakt_client
import opensubtitles as opensubs_client
-from transcode import transcode_quick, transcode_abr, srt_to_vtt
+from transcode import transcode_quick, transcode_abr, transcode_audio_fix, srt_to_vtt
ROOT_DIR = Path(__file__).parent
@@ -585,6 +585,8 @@ async def _process_job(job: dict):
try:
if job["quality"] == "abr":
await transcode_abr(source, out_dir, cb)
+ elif job["quality"] == "audio":
+ await transcode_audio_fix(source, out_dir, cb)
else:
await transcode_quick(source, out_dir, cb)
except Exception as e:
@@ -648,10 +650,10 @@ async def _enqueue_transcode(content_id: str, quality: str, triggered_by: str =
@api.post("/movies/{movie_id}/transcode")
async def trigger_transcode(movie_id: str, payload: Optional[dict] = None, user: dict = Depends(require_admin)):
- """Body: {"quality": "quick"|"abr"} — default "quick". Adds to background queue."""
+ """Body: {"quality": "quick"|"abr"|"audio"} — default "quick". Adds to background queue."""
quality = (payload or {}).get("quality", "quick")
- if quality not in ("quick", "abr"):
- raise HTTPException(status_code=400, detail="quality must be 'quick' or 'abr'")
+ if quality not in ("quick", "abr", "audio"):
+ raise HTTPException(status_code=400, detail="quality must be 'quick', 'abr', or 'audio'")
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
if not movie: raise HTTPException(status_code=404, detail="Movie not found")
if movie.get("storage_type") not in ("local", "radarr", "scan") or not movie.get("storage_path"):
@@ -718,6 +720,30 @@ async def retry_all_failed(user: dict = Depends(require_admin)):
return {"ok": True, "retried": retried}
+@api.post("/transcode/queue/fix-audio-all")
+async def fix_audio_all(user: dict = Depends(require_admin)):
+ """Replaces every still-pending job (not yet started) with a fast audio-only fix job for
+ the same content — for libraries where the source audio is AC3/DTS/EAC3 (unplayable in any
+ browser), this gets working sound out much sooner than waiting on the full ABR backlog to
+ catch up, since only the audio track gets re-encoded rather than the whole video ladder.
+ Jobs already running are left alone; queue a full ABR pass later for real multi-bitrate
+ quality once there's no urgency."""
+ pending_jobs = await db.transcode_queue.find({"status": "pending"}, {"_id": 0}).to_list(5000)
+ cancelled = 0
+ for j in pending_jobs:
+ await db.transcode_queue.update_one({"id": j["id"]}, {"$set": {"status": "cancelled", "finished_at": datetime.now(timezone.utc).isoformat()}})
+ cancelled += 1
+
+ queued = 0
+ for coll, content_type in ((db.movies, "movie"), (db.episodes, "episode")):
+ async for doc in coll.find({"hls_status": {"$ne": "done"}}, {"_id": 0, "id": 1, "storage_type": 1, "storage_path": 1}):
+ if doc.get("storage_type") not in ("local", "radarr", "sonarr", "scan") or not doc.get("storage_path"):
+ continue
+ await _enqueue_transcode(doc["id"], "audio", triggered_by="auto", content_type=content_type)
+ queued += 1
+ return {"ok": True, "cancelled": cancelled, "queued": queued}
+
+
@api.post("/transcode/queue/clear")
async def clear_finished(user: dict = Depends(require_admin)):
res = await db.transcode_queue.delete_many({"status": {"$in": ["done", "failed", "cancelled"]}})
@@ -1222,7 +1248,7 @@ async def create_episode(payload: EpisodeCreate, user: dict = Depends(require_ad
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")
+ if quality not in ("quick", "abr", "audio"): raise HTTPException(status_code=400, detail="Invalid quality")
queued = 0
for eid in ids:
ep = await db.episodes.find_one({"id": eid}, {"_id": 0})
@@ -1256,7 +1282,7 @@ async def bulk_delete_eps(payload: dict, user: dict = Depends(require_admin)):
@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")
+ if quality not in ("quick", "abr", "audio"): 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", "scan") or not ep.get("storage_path"):
diff --git a/backend/transcode.py b/backend/transcode.py
index a59e851..3734344 100644
--- a/backend/transcode.py
+++ b/backend/transcode.py
@@ -56,6 +56,28 @@ def variants_for_source(src_height: int) -> List[Tuple[int, str, str, str, str]]
return chosen
+async def transcode_audio_fix(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
+ """Video stream-copied untouched, audio re-encoded to AAC — fixes browsers' inability to
+ play AC3/DTS/EAC3 (common on BDRips) without paying for a full video re-encode. Same
+ H.264-source assumption as transcode_quick; HEVC/x265 sources still need the full ABR path
+ since the video itself isn't browser-playable either in that case."""
+ if not source.is_file():
+ await on_status("failed", error=f"Source missing: {source}")
+ return
+ out_dir.mkdir(parents=True, exist_ok=True)
+ cmd = [
+ "nice", "-n", str(TRANSCODE_NICE),
+ "ffmpeg", "-y", "-i", str(source),
+ "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ac", "2",
+ "-bsf:v", "h264_mp4toannexb",
+ "-f", "hls", "-hls_time", "6",
+ "-hls_list_size", "0", "-hls_playlist_type", "vod",
+ "-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
+ str(out_dir / "playlist.m3u8"),
+ ]
+ await _run(cmd, on_status, out_dir, "playlist.m3u8")
+
+
async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
"""Stream-copy to single-rate HLS. Output filename: playlist.m3u8."""
if not source.is_file():
diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx
index 737a839..7d20039 100644
--- a/frontend/src/pages/Admin.jsx
+++ b/frontend/src/pages/Admin.jsx
@@ -128,6 +128,10 @@ export default function Admin() {
title="Quick transcode (stream-copy, single bitrate)"
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
data-testid={`transcode-quick-${m.id}`}>Quick
+