mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 18:36:55 +00:00
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:
parent
0e41813e57
commit
66cf9f4089
|
|
@ -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)])
|
||||
|
|
|
|||
|
|
@ -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=>({"&":"&","<":"<",">":">",'"':"""}[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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user