fix(podcaster): off-loop audiobook extraction + persistent error toasts (ref #853)

The audiobook ZIP handler extracted synchronously on the single-worker event
loop. A ~120MB/20-track audiobook took longer than nginx's 30s proxy_read_timeout,
so nginx returned 502 WHILE the extraction actually completed server-side — a
false-negative "Upload failed" on a successful upload (and it froze the service
meanwhile).

- extraction moved to asyncio.to_thread (_publish_audiobook_zip); the event
  loop stays responsive and the response is sent as soon as it finishes.
- ClientDisconnect during body streaming is caught → clean 400, not a 500.
- any extraction failure now deletes the partial feed (no orphaned feed; the
  prior code only cleaned up the "no audio found" case).

Webui (per operator guideline): fatal messages (API/HTTP/missing) now persist
until the user dismisses them (red toast with ✕); progress + success stay
transient. Routed all 7 error sites to errorToast.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-14 08:57:13 +02:00
parent 0e41813e57
commit 66cf9f4089
2 changed files with 55 additions and 21 deletions

View File

@ -29,6 +29,7 @@ from xml.etree import ElementTree as ET
import httpx
from fastapi import FastAPI, APIRouter, BackgroundTasks, Depends, HTTPException, Request
from starlette.requests import ClientDisconnect
from fastapi.responses import Response, FileResponse, JSONResponse
from pydantic import BaseModel
@ -497,15 +498,33 @@ async def audiobook_upload(request: Request, title: str = "Audiobook"):
title = (title or "Audiobook").strip() or "Audiobook"
tmp = Path(tempfile.mkstemp(suffix=".zip", dir="/var/lib/secubox/podcaster")[1])
try:
with open(tmp, "wb") as fh:
async for chunk in request.stream():
fh.write(chunk)
if not zipfile.is_zipfile(tmp):
raise HTTPException(400, "not a valid ZIP")
# synthetic feed
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "audiobook"
fid = store.add_feed(f"audiobook:{slug}:{int(time.time())}",
{"title": title, "description": f"Audiobook: {title}"})
try:
with open(tmp, "wb") as fh:
async for chunk in request.stream():
fh.write(chunk)
except ClientDisconnect:
# Upload aborted / a proxy in front cut the body: no partial feed to
# clean up yet (extraction hasn't run), just report it cleanly.
raise HTTPException(400, "upload interrupted before completion")
# Extraction (zip walk + copy of possibly hundreds of MB) is blocking;
# run it OFF the single-worker event loop so a large audiobook cannot
# freeze the service (and delay the response past the proxy read
# timeout, which was surfacing as a 502).
return await asyncio.to_thread(_publish_audiobook_zip, tmp, title)
finally:
tmp.unlink(missing_ok=True)
def _publish_audiobook_zip(tmp: Path, title: str) -> dict:
"""Blocking: validate the ZIP, create a synthetic feed and extract its audio
tracks as already-'done' episodes. On ANY failure the partial feed is
removed so a broken upload never leaves an orphaned feed behind."""
if not zipfile.is_zipfile(tmp):
raise HTTPException(400, "not a valid ZIP")
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "audiobook"
fid = store.add_feed(f"audiobook:{slug}:{int(time.time())}",
{"title": title, "description": f"Audiobook: {title}"})
try:
fdir = MEDIA / str(fid)
fdir.mkdir(parents=True, exist_ok=True)
tracks = 0
@ -540,8 +559,11 @@ async def audiobook_upload(request: Request, title: str = "Audiobook"):
raise HTTPException(400, "no audio files found in ZIP")
log.info(f"audiobook '{title}' -> feed {fid}, {tracks} tracks")
return {"title": title, "feed_id": fid, "tracks": tracks}
finally:
tmp.unlink(missing_ok=True)
except HTTPException:
raise
except Exception:
store.delete_feed(fid) # never leave a half-extracted feed behind
raise
@router.get("/episodes", dependencies=[Depends(require_jwt)])

View File

@ -61,7 +61,12 @@
.muted{color:var(--text-muted)}.empty{color:var(--text-muted);padding:40px;text-align:center}
dialog{background:var(--panel);color:var(--text-primary);border:1px solid var(--void-purple);border-radius:12px;padding:20px;width:min(520px,92vw)}
dialog label{display:block;margin:10px 0 4px;color:var(--text-muted);font-size:12px}dialog input{width:100%}
.toast{position:fixed;bottom:18px;right:18px;background:#13131c;border:1px solid var(--cyber-cyan);color:var(--cyber-cyan);padding:10px 14px;border-radius:8px;opacity:0;transition:.2s;font-size:12px}.toast.on{opacity:1}
.toast{position:fixed;bottom:18px;right:18px;background:#13131c;border:1px solid var(--cyber-cyan);color:var(--cyber-cyan);padding:10px 14px;border-radius:8px;opacity:0;transition:.2s;font-size:12px;display:flex;gap:10px;align-items:center;max-width:min(90vw,420px);pointer-events:none}.toast.on{opacity:1;pointer-events:auto}
/* fatal errors persist until dismissed (guideline): red, with a ✕ */
.toast.err{border-color:var(--cinnabar);color:var(--cinnabar);background:#1c1113}
.toast .toast-x{flex:none;background:none;border:none;color:inherit;font-size:14px;line-height:1;padding:0 2px;cursor:pointer;opacity:.75}
.toast .toast-x:hover{opacity:1}
.toast .toast-msg{flex:1;word-break:break-word}
</style>
</head>
<body>
@ -126,7 +131,14 @@ async function api(p,opt={}){
if(!r.ok) throw new Error(await r.text());
const ct=r.headers.get("content-type")||""; return ct.includes("json")?r.json():r.text();
}
function toast(m){const t=document.getElementById("toast");t.textContent=m;t.classList.add("on");clearTimeout(t._);t._=setTimeout(()=>t.classList.remove("on"),3000)}
// Transient status: progress + success auto-hide after 3s.
function toast(m){const t=document.getElementById("toast");clearTimeout(t._);t.classList.remove("err");t.textContent=m;t.classList.add("on");t._=setTimeout(()=>t.classList.remove("on"),3000)}
// Fatal (API error / missing): stays until the user dismisses it (guideline).
function errorToast(m){const t=document.getElementById("toast");clearTimeout(t._);
t.textContent="";const s=document.createElement("span");s.className="toast-msg";s.textContent=m;
const x=document.createElement("button");x.className="toast-x";x.title="dismiss";x.textContent="✕";
x.onclick=()=>{t.classList.remove("on","err")};
t.append(s,x);t.classList.add("on","err")}
function esc(s){return (s||"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]))}
async function loadStatus(){
try{const s=await fetch(API+"/status").then(r=>r.json());
@ -166,14 +178,14 @@ async function addFeed(){const u=document.getElementById("feedUrl").value.trim()
try{const r=await api("/feeds",{method:"POST",body:JSON.stringify({url:u,auto_dl:ad})});
document.getElementById("feedUrl").value="";
toast(`Added ${r.title||u} (${r.episodes} eps${r.queued?', '+r.queued+' queued':''})`);loadAll()}
catch(e){toast("Add failed: "+e.message.slice(0,60))}}
catch(e){errorToast("Add failed: "+e.message.slice(0,60))}}
async function toggleAd(id,on){try{const r=await api(`/feeds/${id}/autodl?on=${on?'true':'false'}`,{method:"POST"});
toast(on?`auto-download on${r.queued?' · '+r.queued+' queued':''}`:"auto-download off");loadAll()}catch(e){toast("toggle failed")}}
toast(on?`auto-download on${r.queued?' · '+r.queued+' queued':''}`:"auto-download off");loadAll()}catch(e){errorToast("toggle failed")}}
async function delFeed(id){if(!confirm("Remove this feed?"))return;await api("/feeds/"+id,{method:"DELETE"});if(SEL===id)SEL=null;loadAll()}
async function dl(id){try{await api(`/episodes/${id}/download`,{method:"POST"});toast("Queued");loadEps()}catch(e){toast("Queue failed")}}
async function dl(id){try{await api(`/episodes/${id}/download`,{method:"POST"});toast("Queued");loadEps()}catch(e){errorToast("Queue failed")}}
async function importOpml(inp){const f=inp.files[0];if(!f)return;const opml=await f.text();
try{const r=await api("/feeds/import-opml",{method:"POST",body:JSON.stringify({opml})});toast(`Imported ${r.added}/${r.total}`);loadAll()}catch(e){toast("OPML import failed")}inp.value=""}
function stickyToast(m){const t=document.getElementById("toast");t.textContent=m;t.classList.add("on");clearTimeout(t._)}
try{const r=await api("/feeds/import-opml",{method:"POST",body:JSON.stringify({opml})});toast(`Imported ${r.added}/${r.total}`);loadAll()}catch(e){errorToast("OPML import failed")}inp.value=""}
function stickyToast(m){const t=document.getElementById("toast");clearTimeout(t._);t.classList.remove("err");t.textContent=m;t.classList.add("on")}
function uploadZip(inp){const f=inp.files[0];if(!f)return;
const title=prompt("Audiobook title:",f.name.replace(/\.zip$/i,""));if(title===null){inp.value="";return}
const xhr=new XMLHttpRequest();
@ -188,8 +200,8 @@ function uploadZip(inp){const f=inp.files[0];if(!f)return;
if(xhr.status===401){window.location="/login.html";return}
if(xhr.status>=200&&xhr.status<300){ let r={}; try{r=JSON.parse(xhr.responseText)}catch(_){}
toast(`Published "${r.title||title}" — ${r.tracks||0} tracks`); loadAll(); }
else toast("Upload failed: "+((xhr.responseText||("HTTP "+xhr.status)).slice(0,80))); };
xhr.onerror=()=>{ inp.value=""; toast("Upload failed: network error"); };
else errorToast("Upload failed: "+((xhr.responseText||("HTTP "+xhr.status)).slice(0,80))); };
xhr.onerror=()=>{ inp.value=""; errorToast("Upload failed: network error"); };
stickyToast(`Uploading ${f.name} — 0%`);
xhr.send(f);
}
@ -198,7 +210,7 @@ async function openCfg(){const d=await api("/config").then(r=>r.config).catch(()
document.getElementById("svc").innerHTML=`service <b style="color:var(--matrix-green)">${s.service||'?'}</b> · worker ${s.worker?'on':'off'} · ${s.downloaded||0} local`;
if(d){c_media.value=d.media_path||"";c_par.value=d.max_parallel||2;c_ref.value=d.refresh_minutes||60;c_pub.value=d.public_base||"";c_title.value=d.share_title||""}
document.getElementById("cfg").showModal()}
async function saveCfg(){try{await api("/config",{method:"POST",body:JSON.stringify({media_path:c_media.value,max_parallel:+c_par.value,refresh_minutes:+c_ref.value,public_base:c_pub.value,share_title:c_title.value})});toast("Saved — restart to apply");document.getElementById("cfg").close()}catch(e){toast("Save failed")}}
async function saveCfg(){try{await api("/config",{method:"POST",body:JSON.stringify({media_path:c_media.value,max_parallel:+c_par.value,refresh_minutes:+c_ref.value,public_base:c_pub.value,share_title:c_title.value})});toast("Saved — restart to apply");document.getElementById("cfg").close()}catch(e){errorToast("Save failed")}}
function loadAll(){loadStatus();loadFeeds();loadEps()}
loadAll();setInterval(()=>{loadStatus();loadEps()},4000);
</script>