Add fast audio-only transcode mode; fixes no-sound from AC3/DTS/EAC3

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.
This commit is contained in:
Myron Blair
2026-07-26 23:31:23 -05:00
parent cf8c7890be
commit b74d8a2652
4 changed files with 73 additions and 7 deletions
+32 -6
View File
@@ -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"):
+22
View File
@@ -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():
+4
View File
@@ -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</button>
<button onClick={() => startTranscode(m, "audio")} disabled={transcoding[m.id]}
title="Fix audio only (video untouched, audio re-encoded to AAC) — fast, fixes no-sound from AC3/DTS/EAC3"
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
data-testid={`transcode-audio-${m.id}`}>Audio</button>
<button onClick={() => startTranscode(m, "abr")} disabled={transcoding[m.id]}
title="Adaptive bitrate (re-encodes to 360/480/720/1080p — slow)"
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"
+15 -1
View File
@@ -1,7 +1,7 @@
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";
import { RefreshCcw, X, RotateCcw, Pause, Play as PlayIcon, Trash2, Volume2 } from "lucide-react";
const STATUS_COLORS = {
pending: "text-[#fcd34d]",
@@ -60,6 +60,17 @@ export default function TranscodeQueue() {
} catch { toast.error("Could not toggle pause"); }
};
const fixAudioAll = async () => {
if (!window.confirm(
"Cancels every job still pending (not yet started) and replaces it with a fast audio-only fix — video is left untouched, only the audio track is re-encoded to AAC. This is much faster than the full ABR queue and fixes \"no sound\" caused by AC3/DTS/EAC3 audio (common on BDRips), which browsers can't play natively. Continue?"
)) return;
try {
const { data } = await api.post("/transcode/queue/fix-audio-all");
toast.success(`Queued audio fix for ${data.queued} item(s), replacing ${data.cancelled} pending job(s)`);
load();
} catch (err) { toast.error(err.response?.data?.detail || "Could not start"); }
};
const clearFinished = async () => {
if (!window.confirm("Remove all done/failed/cancelled jobs from the history?")) return;
try { const { data } = await api.post("/transcode/queue/clear"); toast.success(`Cleared ${data.deleted}`); load(); }
@@ -97,6 +108,9 @@ export default function TranscodeQueue() {
<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="queue-retry-all">
<RotateCcw size={14} strokeWidth={1.5} /> Retry All Failed
</button>
<button onClick={fixAudioAll} className="flex items-center gap-2 border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="queue-fix-audio-all">
<Volume2 size={14} strokeWidth={1.5} /> Fix Audio (Fast, All)
</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="queue-clear">
<Trash2 size={14} strokeWidth={1.5} /> Clear Finished
</button>