From b74d8a2652da28e4a3235b60a74521d4579f1ffe Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sun, 26 Jul 2026 23:31:23 -0500 Subject: [PATCH] Add fast audio-only transcode mode; fixes no-sound from AC3/DTS/EAC3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of "movies have no volume control / can't hear anything": 0 of 200 movies had finished transcoding, so every movie streamed its raw source file — and BDRip/AVI sources very commonly carry AC3/DTS/EAC3 audio, which no browser can decode natively. Browsers respond by hiding the volume control entirely when they can't find a playable audio track, which is exactly the symptom reported. New "audio" transcode quality: video is stream-copied untouched, only the audio track is re-encoded to AAC — dramatically faster than a full ABR pass since no video re-encoding happens. Added: - transcode_audio_fix() in transcode.py - "audio" as a valid quality value everywhere quick/abr were validated - POST /transcode/queue/fix-audio-all: cancels every still-pending job and replaces it with the fast audio fix for the same content (running jobs are left alone), so a library-wide backlog of slow ABR jobs can be swapped for something that actually restores sound soon - Per-movie "Audio" button in Admin, "Fix Audio (Fast, All)" button on the Transcode Queue page Note: this doesn't help HEVC/x265 sources, whose video (not just audio) isn't natively browser-playable either — those still need a full ABR re-encode to H.264. --- backend/server.py | 38 ++++++++++++++++++++++----- backend/transcode.py | 22 ++++++++++++++++ frontend/src/pages/Admin.jsx | 4 +++ frontend/src/pages/TranscodeQueue.jsx | 16 ++++++++++- 4 files changed, 73 insertions(+), 7 deletions(-) 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 + +