mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 05:23:45 -05:00
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:
+32
-6
@@ -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"):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user