Преглед изворни кода

Resolve queue display error and 500 on secondary upload

ArtyomV2X пре 3 недеља
родитељ
комит
9cf5f6eeac
2 измењених фајлова са 94 додато и 13 уклоњено
  1. 65 8
      app.py
  2. 29 5
      index.html

+ 65 - 8
app.py

@@ -41,8 +41,22 @@ TOKEN_FILE = Path(os.environ.get("WEBRAKE_TOKENS", str(APP_DIR / "token.json")))
 for d in (UPLOAD_DIR, OUTPUT_DIR, JOBS_DIR):
     d.mkdir(parents=True, exist_ok=True)
 
+# Spool any temporary files onto the data volume, never /tmp — on Alpine /tmp
+# can be a small RAM-backed tmpfs, which silently caps upload sizes.
+import tempfile
+SCRATCH = DATA_DIR / "tmp"
+SCRATCH.mkdir(parents=True, exist_ok=True)
+os.environ["TMPDIR"] = str(SCRATCH)
+tempfile.tempdir = str(SCRATCH)
+
+def storage_info():
+    u = shutil.disk_usage(DATA_DIR)
+    return {"total": u.total, "used": u.used, "free": u.free}
+
+FREE_SPACE_MARGIN = 256 * 1024 * 1024  # keep 256 MB headroom for outputs/logs
+
 HANDBRAKE = shutil.which("HandBrakeCLI") or "/usr/bin/HandBrakeCLI"
-VERSION   = "1.0.3"
+VERSION   = "1.0.4"
 
 def load_tokens():
     """token.json is created by install.sh and is never part of the repo."""
@@ -518,15 +532,53 @@ def api_presets():
     except Exception as e:
         return jsonify({"presets": [], "error": str(e)})
 
-@app.route("/api/upload", methods=["POST"])
+@app.route("/api/upload", methods=["POST", "PUT"])
 @auth_required
 def api_upload():
-    f = request.files.get("file")
-    if not f or not f.filename:
-        abort(400, description="No file supplied")
-    safe = re.sub(r"[^\w.\- ()\[\]]", "_", os.path.basename(f.filename))
+    # Preferred path: raw body streaming (?filename=… or X-Filename header) —
+    # constant memory, no multipart spooling. Multipart 'file' still accepted
+    # for curl/bot compatibility.
+    raw_name = request.args.get("filename") or request.headers.get("X-Filename")
+    mp = None if raw_name else request.files.get("file")
+    if not raw_name and (not mp or not mp.filename):
+        abort(400, description="No file supplied — send raw body with "
+                               "?filename=… or multipart field 'file'")
+    name = os.path.basename(raw_name or mp.filename)
+    safe = re.sub(r"[^\w.\- ()\[\]]", "_", name) or "upload.bin"
+
+    # Preflight: refuse uploads that cannot fit, with a number instead of a 500.
+    length = request.content_length or 0
+    free = storage_info()["free"]
+    if length and length + FREE_SPACE_MARGIN > free:
+        return jsonify({"error": "Not enough space on the container volume: "
+                        f"upload is {length/1e9:.2f} GB but only "
+                        f"{max(free - FREE_SPACE_MARGIN, 0)/1e9:.2f} GB is usable. "
+                        "Delete old sources/outputs or grow the LXC disk."}), 507
+
     dest = UPLOAD_DIR / f"{uuid.uuid4().hex[:8]}_{safe}"
-    f.save(dest)
+    try:
+        if raw_name:
+            with open(dest, "wb") as out:
+                while True:
+                    chunk = request.stream.read(4 * 1024 * 1024)
+                    if not chunk:
+                        break
+                    out.write(chunk)
+        else:
+            mp.save(dest)
+    except OSError as e:
+        dest.unlink(missing_ok=True)  # never leave truncated sources behind
+        import errno
+        if e.errno == errno.ENOSPC:
+            return jsonify({"error": "Disk filled up mid-upload — the partial "
+                            "file was removed. Free space and retry."}), 507
+        return jsonify({"error": f"Could not store upload: {e}"}), 500
+    if raw_name and length and dest.stat().st_size != length:
+        # Only meaningful for raw uploads: multipart Content-Length includes
+        # boundary/header overhead and will never equal the file size.
+        dest.unlink(missing_ok=True)
+        return jsonify({"error": "Upload was truncated in transit — removed. "
+                        "Please retry."}), 500
     return jsonify({"upload_id": dest.name, "filename": safe,
                     "size": dest.stat().st_size})
 
@@ -540,7 +592,7 @@ def api_uploads():
                           "filename": p.name.split("_", 1)[-1],
                           "size": p.stat().st_size,
                           "mtime": p.stat().st_mtime})
-    return jsonify({"uploads": items})
+    return jsonify({"uploads": items, "storage": storage_info()})
 
 @app.route("/api/uploads/<upload_id>", methods=["DELETE"])
 @auth_required
@@ -676,6 +728,11 @@ def api_download(job_id):
 def json_error(err):
     return jsonify({"error": getattr(err, "description", str(err))}), err.code
 
+@app.errorhandler(500)
+def json_500(err):
+    orig = getattr(err, "original_exception", None)
+    return jsonify({"error": f"Server error: {orig or err}"}), 500
+
 if __name__ == "__main__":
     host = os.environ.get("WEBRAKE_HOST", "0.0.0.0")
     port = int(os.environ.get("WEBRAKE_PORT", "8090"))

+ 29 - 5
index.html

@@ -162,6 +162,11 @@ footer{color:var(--ink-soft);font-size:12px;text-align:center;padding-top:8px}
 @keyframes knock{0%,100%{transform:none}50%{transform:translateY(-2px)}}
 @media (prefers-reduced-motion:reduce){.theme-btn.attn{animation:none}}
 .srcerr{color:var(--err);text-align:center;padding:18px;font-size:13px}
+.storage{margin-top:14px}
+.bar.thin{height:6px}
+.storage .hint{margin-top:5px}
+.storage.low .bar>i{background:var(--err);animation:none}
+.storage.low .hint{color:var(--err)}
 .active-src{font-size:13px;color:var(--ink-soft)}
 .active-src b{color:var(--gold)}
 @media(max-width:640px){.job .stats{margin-left:0;width:100%}}
@@ -229,6 +234,10 @@ footer{color:var(--ink-soft);font-size:12px;text-align:center;padding-top:8px}
     <div class="bar"><i id="upfill"></i></div>
     <div class="hint" id="uptext">Uploading…</div>
   </div>
+  <div class="storage" id="storage" hidden>
+    <div class="bar static thin"><i id="storefill"></i></div>
+    <div class="hint" id="storetext"></div>
+  </div>
   <div class="src-list" id="srcList"></div>
 </section>
 
@@ -571,8 +580,13 @@ drop.onkeydown=e=>{ if(e.key==="Enter"||e.key===" ") fi.click(); };
 drop.addEventListener("drop",e=>{ if(e.dataTransfer.files.length) upload(e.dataTransfer.files[0]); });
 fi.onchange=()=>{ if(fi.files.length) upload(fi.files[0]); fi.value=""; };
 
+let FREE_BYTES = null;
 function upload(file){
-  const xhr=new XMLHttpRequest(), fd=new FormData(); fd.append("file",file);
+  if(FREE_BYTES!==null && file.size > FREE_BYTES - 256*1024*1024){
+    toast(`Not enough space: ${fmtSize(file.size)} file, ${fmtSize(FREE_BYTES)} free on the container`);
+    return;
+  }
+  const xhr=new XMLHttpRequest();
   $("#upbar").classList.add("show");
   const fill=$("#upfill"), txt=$("#uptext");
   xhr.upload.onprogress=e=>{
@@ -592,18 +606,28 @@ function upload(file){
     else if(xhr.status===401){ markAuthNeeded(); toast("Upload needs an API token"); }
     else{
       let msg=""; try{ msg=JSON.parse(xhr.responseText).error }catch{}
-      toast("Upload failed ("+xhr.status+")"+(msg?": "+msg:""));
+      toast(msg || "Upload failed ("+xhr.status+")");
+      refreshUploads(); // pick up fresh storage numbers after a space failure
     }
   };
-  xhr.onerror=()=>{ $("#upbar").classList.remove("show"); toast("Upload failed"); };
-  xhr.open("POST","/api/upload");
+  xhr.onerror=()=>{ $("#upbar").classList.remove("show"); toast("Upload failed — network error"); };
+  xhr.open("POST","/api/upload?filename="+encodeURIComponent(file.name));
   if(API_TOKEN) xhr.setRequestHeader("X-API-Token",API_TOKEN);
-  xhr.send(fd);
+  xhr.send(file);   // raw body stream — no multipart, no server-side spooling
 }
 
 async function refreshUploads(){
   try{
     const d = await (await api("/api/uploads")).json();
+    if(d.storage){
+      FREE_BYTES = d.storage.free;
+      const s=$("#storage"); s.hidden=false;
+      const usedPct = d.storage.total ? (d.storage.used/d.storage.total*100) : 0;
+      $("#storefill").style.width = usedPct.toFixed(1)+"%";
+      $("#storetext").textContent =
+        `Container storage: ${fmtSize(d.storage.free)} free of ${fmtSize(d.storage.total)}`;
+      s.classList.toggle("low", d.storage.free < Math.max(d.storage.total*0.08, 1e9));
+    }
     const list=$("#srcList"); list.innerHTML="";
     d.uploads.forEach(u=>{
       const row=document.createElement("div");