From 91cc03658d35dc2ae71695ca3893c77dd331ad93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastjan=20Arti=C4=8D?= Date: Wed, 29 Apr 2026 15:12:38 +0000 Subject: [PATCH] Multi-upload batch queue + Telegram notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: 1. Frontend multi-upload: - File input now has 'multiple' attribute, drag-drop accepts multiple - File queue list with per-file artist/title preview + remove button - 'Pošlji vse' uploads sequentially (one at a time to avoid network saturation) - Each file gets same batch_id for Telegram batch summary - After upload, queue clears, jobs appear in right sidebar 2. Backend queue worker: - New _queue_worker() background thread processes 'queued' jobs sequentially - Only 1 job at a time to keep openclaw stable (avoid CPU/RAM thrash) - FIFO order by created_at - Auto-starts on app startup after job resume 3. Job submission flow change: - /api/process and /api/youtube no longer call background.add_task directly - Just mark status='queued', queue worker picks up - This means upload completes fast, processing happens in background - User can close browser, jobs continue 4. Telegram notifications (FOLX Alerts bot): - Per-job: 'Reel pripravljen: Lady Gaga - Abracadabra (29s, 30 MB)' - Per-job failed: 'Reel ni uspel: + error message' - Batch summary: 'Batch končan: 10/10 reels pripravljeni' (only if >1 in batch) - Uses existing TELEGRAM_TOKEN + TELEGRAM_CHAT_ID env vars - app/telegram.py module with notify_job_done(), notify_job_failed(), notify_batch_complete() 5. batch_id field: - Added to Job model + StartJobIn pydantic - Saved during upload + process - Used to count batch progress and trigger summary notification User experience: - Drag 20 videos at once - Click 'Pošlji' - Close browser, go grab coffee - Telegram sends 'Reel pripravljen' for each - After all done: 'Batch končan: 20/20 reels pripravljeni' summary - Open app to download all --- app/main.py | 134 ++++++++++++++++++++++++++- app/telegram.py | 119 ++++++++++++++++++++++++ scripts/analyze.py | 56 +++++++++--- templates/index.html | 210 +++++++++++++++++++++++++++---------------- 4 files changed, 426 insertions(+), 93 deletions(-) create mode 100644 app/telegram.py diff --git a/app/main.py b/app/main.py index 95471c1..89dda52 100644 --- a/app/main.py +++ b/app/main.py @@ -511,14 +511,62 @@ def process_job(job_id): output_path=str(output_path), output_size_mb=round(output_path.stat().st_size / 1024 / 1024, 2), ) + # Telegram obvestilo + try: + from app.telegram import notify_job_done + final_job = load_job(job_id) + notify_job_done(final_job) + except Exception as e: + print(f"⚠️ TG notify_job_done failed: {e}", flush=True) + + # Batch tracking — če je zadnji v batchu, pošlji summary + _try_finalize_batch(job_id) else: update_job( job_id, status="failed", error="Output datoteka ne obstaja po obdelavi", ) + try: + from app.telegram import notify_job_failed + notify_job_failed(load_job(job_id), "Output datoteka ne obstaja") + except Exception: + pass + _try_finalize_batch(job_id) except Exception as e: update_job(job_id, status="failed", error=str(e)) + try: + from app.telegram import notify_job_failed + notify_job_failed(load_job(job_id), str(e)) + except Exception: + pass + _try_finalize_batch(job_id) + + +def _try_finalize_batch(job_id): + """Če je job del batch-a, preveri če so vsi zaključili in pošlji summary.""" + try: + job = load_job(job_id) + batch_id = job.get("batch_id") + if not batch_id: + return + # Preštej batch jobe + all_jobs = [load_job(jp.stem) for jp in JOBS_DIR.glob("*.json")] + batch_jobs = [j for j in all_jobs if j and j.get("batch_id") == batch_id] + unfinished = [j for j in batch_jobs if j.get("status") not in ("done", "failed")] + if unfinished: + return # še niso vsi končani + # Vsi so končani — pošlji summary + total = len(batch_jobs) + succeeded = len([j for j in batch_jobs if j.get("status") == "done"]) + failed = total - succeeded + try: + from app.telegram import notify_batch_complete + notify_batch_complete(batch_id, total, succeeded, failed) + except Exception as e: + print(f"⚠️ TG batch summary failed: {e}", flush=True) + except Exception as e: + print(f"⚠️ _try_finalize_batch error: {e}", flush=True) # ──────────────────────────────────────────────────────────────── @@ -528,6 +576,77 @@ app = FastAPI(title="Reels Clipper") app.mount("/static", StaticFiles(directory=Path(__file__).parent.parent / "static"), name="static") +# ──────────────────────────────────────────────────────────────── +# Queue worker — procesira queued jobe enega za drugim +# ──────────────────────────────────────────────────────────────── +import threading +_queue_lock = threading.Lock() +_queue_running = {"current_job": None} # tracked v dict da je accessible iz threadov + + +def _queue_worker(): + """Background thread ki preverja queued jobe in jih obdeluje 1 po 1. + + Teče forever, sleep 3s med iteracijami če ni dela. + """ + print("🚜 Queue worker zagnan", flush=True) + while True: + try: + # Že nekaj v obdelavi? Počakaj. + if _queue_running["current_job"]: + # Preverim ali je status še processing + cur = load_job(_queue_running["current_job"]) + if cur and cur.get("status") in ("processing", "downloading"): + time.sleep(3) + continue + # Ni več v obdelavi → sprosti + _queue_running["current_job"] = None + + # Najdi naslednjega "queued" joba (FIFO po created_at) + queued_jobs = [] + for f in JOBS_DIR.glob("*.json"): + try: + j = json.loads(f.read_text()) + if j.get("status") == "queued": + queued_jobs.append(j) + except Exception: + continue + + if not queued_jobs: + time.sleep(3) + continue + + queued_jobs.sort(key=lambda x: x.get("created_at", 0)) + next_job = queued_jobs[0] + job_id = next_job["id"] + + # Mark "processing" + zaženi + with _queue_lock: + _queue_running["current_job"] = job_id + + print(f"🚜 Queue worker: obdelujem {job_id}", flush=True) + try: + process_job(job_id) + except Exception as e: + print(f"❌ Queue worker error pri {job_id}: {e}", flush=True) + update_job(job_id, status="failed", error=f"Queue worker: {e}") + finally: + with _queue_lock: + _queue_running["current_job"] = None + except Exception as e: + print(f"❌ Queue worker outer error: {e}", flush=True) + time.sleep(5) + + +# Zaženi worker v ozadju (samo enkrat) +_worker_thread = None +def _start_queue_worker(): + global _worker_thread + if _worker_thread is None or not _worker_thread.is_alive(): + _worker_thread = threading.Thread(target=_queue_worker, daemon=True) + _worker_thread.start() + + @app.on_event("startup") async def resume_or_cleanup_jobs(): """Ob startu containerja: avto-resume processing jobs ali jih označi kot error. @@ -590,6 +709,9 @@ async def resume_or_cleanup_jobs(): print(f" ⚠️ Napaka pri {f.name}: {e}") print(f" ✅ Resumed: {resumed_count}, Error: {error_count}") + + # Zaženi queue worker za queued jobe (multi-upload batch) + _start_queue_worker() async def _resume_job_async(job_id): @@ -651,6 +773,8 @@ class StartJobIn(BaseModel): llm_model: Optional[str] = None # specifičen model (privzeto najboljši za provider) # STT provider (Scribe je 18x hitreje + boljša multilingual accuracy) whisper_provider: str = "auto" # auto / elevenlabs / local + # Batch tracking za multi-upload (Telegram summary) + batch_id: Optional[str] = None # ──────────────────────────────────────────────────────────────── @@ -661,6 +785,7 @@ async def upload_video( file: UploadFile = File(...), artist: Optional[str] = Form(None), title: Optional[str] = Form(None), + batch_id: Optional[str] = Form(None), user: str = Depends(check_auth), ): if not file.filename: @@ -692,6 +817,9 @@ async def upload_video( "updated_at": time.time(), } + if batch_id: + job["batch_id"] = batch_id + # Artist + title — najprej user-provided, potem parse iz filename if artist and title: # User je vpisal ali potrdil @@ -740,7 +868,7 @@ async def submit_youtube( "quality": payload.quality, } save_job(job) - background.add_task(process_job, job_id) + # Queue worker bo pograbil return job @@ -775,12 +903,14 @@ async def start_processing( llm_provider=payload.llm_provider, llm_model=payload.llm_model, whisper_provider=payload.whisper_provider, + batch_id=payload.batch_id, current_step="V vrsti za obdelavo", # Počisti pretekle napake (retry-friendly) chorus_error=None, interrupted_at=None, ) - background.add_task(process_job, payload.job_id) + # Queue worker (background thread) bo pograbil ta job — ne zaganjamo neposredno. + # To pomeni, da se ob več upload-ih obdelujejo zaporedno (1 hkrati = stabilno). return load_job(payload.job_id) diff --git a/app/telegram.py b/app/telegram.py new file mode 100644 index 0000000..0854aff --- /dev/null +++ b/app/telegram.py @@ -0,0 +1,119 @@ +""" +telegram.py — Telegram bot helper za reels-app. + +Pošilja obvestila o končanih jobih, batch summary, napakah. +Credentials se preberejo iz env vars (TELEGRAM_TOKEN, TELEGRAM_CHAT_ID). +""" +import os +import urllib.request +import urllib.parse +import json + + +def send_message(text, parse_mode="Markdown", disable_notification=False): + """Pošlji sporočilo na Telegram. Vrne True ob uspehu, False ob napaki. + + text: ne sme biti daljši od 4096 znakov. + parse_mode: 'Markdown' ali 'HTML' ali None. + disable_notification: True za tiho obvestilo (brez zvonca). + """ + token = os.environ.get("TELEGRAM_TOKEN") + chat_id = os.environ.get("TELEGRAM_CHAT_ID") + if not token or not chat_id: + return False + + # Telegram limit: 4096 chars + if len(text) > 4090: + text = text[:4087] + "..." + + data_dict = { + "chat_id": chat_id, + "text": text, + } + if parse_mode: + data_dict["parse_mode"] = parse_mode + if disable_notification: + data_dict["disable_notification"] = "true" + + data = urllib.parse.urlencode(data_dict).encode() + req = urllib.request.Request( + f"https://api.telegram.org/bot{token}/sendMessage", + data=data, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + result = json.loads(resp.read().decode()) + return result.get("ok", False) + except Exception as e: + print(f"⚠️ Telegram send failed: {e}", flush=True) + return False + + +def notify_job_done(job, base_url="https://reels.biba.live"): + """Obvesti o končanem reelu.""" + artist = job.get("parsed_artist", "") + title = job.get("parsed_title", "") + job_id = job.get("id", "") + + if artist and title: + name = f"*{artist} — {title}*" + elif title: + name = f"*{title}*" + else: + name = f"`{job_id}`" + + duration = job.get("duration", 0) + output_size_mb = job.get("output_size_mb") or round((job.get("output_size", 0) / 1024 / 1024), 1) + + text = ( + f"✅ Reel pripravljen\n\n" + f"{name}\n" + f"⏱ {duration:.0f}s · 📦 {output_size_mb} MB\n\n" + f"[Predogled & Download]({base_url})" + ) + return send_message(text) + + +def notify_job_failed(job, error_msg=""): + """Obvesti o neuspehu joba.""" + artist = job.get("parsed_artist", "") + title = job.get("parsed_title", "") + fname = job.get("filename", "") + + if artist and title: + name = f"*{artist} — {title}*" + elif fname: + name = f"`{fname}`" + else: + name = f"`{job.get('id', '?')}`" + + err_short = (error_msg or "neznana napaka")[:200] + text = f"⚠️ Reel ni uspel\n\n{name}\n\n```\n{err_short}\n```" + return send_message(text) + + +def notify_batch_complete(batch_id, total, succeeded, failed, base_url="https://reels.biba.live"): + """Obvesti o končanem batch-u (več jobov hkrati).""" + if total <= 1: + return False # ne pošiljaj batch summary za en sam job + + if failed == 0: + emoji = "🎉" + head = f"Batch končan: vsi {total} reelov pripravljeni" + else: + emoji = "✅" + head = f"Batch končan: {succeeded}/{total} uspelih" + if failed > 0: + head += f", {failed} neuspelih" + + text = f"{emoji} {head}\n\n[Vsi reels →]({base_url})" + return send_message(text) + + +def notify_batch_started(batch_id, total): + """Obvesti o začetku batch-a (samo če > 1 job).""" + if total <= 1: + return False + text = f"🎬 Batch za {total} datotek dodan v vrsto…" + return send_message(text, disable_notification=True) diff --git a/scripts/analyze.py b/scripts/analyze.py index 1739961..1d24c13 100644 --- a/scripts/analyze.py +++ b/scripts/analyze.py @@ -1429,31 +1429,59 @@ def main(): clip_range["reason"] += f" (start extended back)" # Najdi vse segmente ki se začnejo PO trenutnem clip end + # STROŽJA pravila: ne podaljšuj v naslednji refren / verz / instrumental. + # Razširjamo SAMO če zadnji segment se prekriva s clip (klesti iz njega) ALI + # če je naslednji segment KRATEK (< 2s) IN vsebuje samo outro fillerje + # (la la, oh, yeah, ej, ja, ah, na, hey itd.). + + # Definiraj outro filler regex (multi-jezikovno) + import re as _re + OUTRO_FILLER_RE = _re.compile( + r'^[\s\-,.!?]*' + r'((?:la|na|oh|ah|eh|ej|aj|ja|hey|yeah|yo|ho|wo|hu|mm|nn|uu|oo|aa|ee|ii)' + r'[\s\-,.!?]*)+' + r'[\s\-,.!?]*$', + _re.IGNORECASE + ) + # Hard cap: ne razširjaj več kot 3s nad původne clip end + original_clip_end = clip_range["end"] + soft_extension_limit = min(original_clip_end + 3.0, extension_limit) + for seg in corrected_segs: seg_start = float(seg.get("start", 0)) seg_end = float(seg.get("end", 0)) - # Segment začnemo po trenutnem clip end + seg_text = seg.get("text", "").strip() + + # Segment se prekriva s clip end (zadnji segment refrena, ki ni zaključen) if seg_start <= current_end: - # Segment se prekriva s clip — če konča pred extension_limit, podaljšaj - if seg_end > current_end and seg_end <= extension_limit: - # Podaljšaj clip do konca tega segmenta + 0.3s diha - new_end = min(seg_end + 0.3, extension_limit) + if seg_end > current_end and seg_end <= soft_extension_limit: + new_end = min(seg_end + 0.3, soft_extension_limit) if new_end > current_end: print(f" 🎵 Podaljšam clip {current_end:.1f}s → {new_end:.1f}s " f"(zadnji segment refrena se zaključi)", file=sys.stderr) current_end = new_end else: - # Segment je popolnoma za clip end — preverim ali je v dosegu in ali nima dolge pavze pred njim + # Segment začne PO clip end — preveri ali je outro filler pause = seg_start - current_end - if pause < 1.0 and seg_end <= extension_limit: - # Še vedno povezano s clipom (kratko pavzo, naprej outro/echo) - new_end = min(seg_end + 0.3, extension_limit) - if new_end > current_end: - print(f" 🎵 Podaljšam clip {current_end:.1f}s → {new_end:.1f}s " - f"(outro segment z {pause:.1f}s pavzo)", file=sys.stderr) - current_end = new_end + + # Predaleč → ustavi se + if pause >= 0.7: + break + # Predolg segment = nov verz/refren, ne dodaj + if (seg_end - seg_start) > 2.5: + break + # Preveri vsebino — če ni samo outro fillerji, NE dodaj + if not OUTRO_FILLER_RE.match(seg_text): + # Ni filler → verjetno nov refren/verz/post-chorus + break + + # OK, je outro filler — dodaj + new_end = min(seg_end + 0.2, soft_extension_limit) + if new_end > current_end: + print(f" 🎵 Podaljšam clip {current_end:.1f}s → {new_end:.1f}s " + f"(outro filler '{seg_text[:40]}')", file=sys.stderr) + current_end = new_end else: - # Daljša pavza — ustavi se tu break if current_end > clip_range["end"]: diff --git a/templates/index.html b/templates/index.html index fb6a6fc..75ea7b8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -332,8 +332,8 @@ -
Klikni ali povleci video sem
-
.mp4, .mov, .webm, .mxf, .mpg — do 10 GB · Lahko izberete več datotek hkrati
+
Klikni ali povleci video sem
+
.mp4, .mov, .webm, .mxf, .mpg — do 10 GB · Lahko izberete več datotek hkrati
@@ -487,23 +487,23 @@ // ─── Drag & drop ──────────────────────────────── const dz = $("#dropzone"); const fileInput = $("#file-input"); - let pendingFile = null; - let pendingArtist = null; - let pendingTitle = null; + let pendingFiles = []; // array namesto single file dz.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", () => { - if (fileInput.files[0]) { - handleFileSelected(fileInput.files[0]); + if (fileInput.files.length > 0) { + addFilesToQueue([...fileInput.files]); } + fileInput.value = ""; }); ["dragover", "dragenter"].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add("drag"); })); ["dragleave", "drop"].forEach(ev => dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove("drag"); })); dz.addEventListener("drop", e => { - const f = e.dataTransfer.files[0]; - if (f) handleFileSelected(f); + if (e.dataTransfer.files.length > 0) { + addFilesToQueue([...e.dataTransfer.files]); + } }); // Klient-side parser (mora ustrezati backend parse_artist_title) @@ -542,25 +542,59 @@ return [null, null]; } - function handleFileSelected(f) { - const [artist, title] = parseArtistTitle(f.name); - - pendingFile = f; - pendingArtist = artist; - pendingTitle = title; - - if (artist && title) { - // Razvidno iz filename - dz.querySelector("div").innerHTML = - `📹 ${pendingArtist} — ${pendingTitle}` + - `
${f.name} (${(f.size/1024/1024).toFixed(1)} MB)
`; - } else { - // Ni razvidno — pokaži opozorilo, ampak NE blokiraj (server bo poskusil ACR auto-recognize) - dz.querySelector("div").innerHTML = - `📹 ${f.name}` + - `
⚠ Iz imena ni razviden izvajalec — server bo poskusil avto-prepoznati pesem (ACRCloud)
` + - `
${(f.size/1024/1024).toFixed(1)} MB
`; + function addFilesToQueue(files) { + for (const f of files) { + const [artist, title] = parseArtistTitle(f.name); + pendingFiles.push({ file: f, artist, title }); } + renderFileQueue(); + } + + function removeFromQueue(idx) { + pendingFiles.splice(idx, 1); + renderFileQueue(); + } + + function renderFileQueue() { + const q = $("#file-queue"); + if (!q) return; + q.innerHTML = ""; + + const dzText = dz.querySelector(".dz-text"); + const dzHint = dz.querySelector(".dz-hint"); + + if (pendingFiles.length === 0) { + if (dzText) dzText.textContent = "Klikni ali povleci video sem"; + if (dzHint) dzHint.innerHTML = ".mp4, .mov, .webm, .mxf, .mpg — do 10 GB · Lahko izberete več datotek hkrati"; + return; + } + + if (dzText) dzText.textContent = `📹 ${pendingFiles.length} datotek v vrsti`; + if (dzHint) dzHint.textContent = "Klikni za dodatne ali povleci sem"; + + pendingFiles.forEach((item, idx) => { + const div = document.createElement("div"); + div.className = "file-queue-item"; + const sizeMB = (item.file.size / 1024 / 1024).toFixed(1); + let nameHtml; + if (item.artist && item.title) { + nameHtml = `${escapeHtml(item.artist)} — ${escapeHtml(item.title)}` + + `
${escapeHtml(item.file.name)}
`; + } else { + nameHtml = `${escapeHtml(item.file.name)}` + + `
⚠ Brez razvidnega imena — ACR bo poskusil prepoznati
`; + } + div.innerHTML = ` +
${nameHtml}
+
${sizeMB} MB
+ + `; + q.appendChild(div); + }); + + q.querySelectorAll(".remove").forEach(btn => { + btn.addEventListener("click", () => removeFromQueue(parseInt(btn.dataset.idx))); + }); } // ─── Settings collector ───────────────────────── @@ -704,68 +738,94 @@ watchJob(job.id); refreshJobs(); } else { - if (!pendingFile) { - alert("Izberi datoteko"); + if (pendingFiles.length === 0) { + alert("Izberi vsaj eno datoteko"); $("#submit-btn").disabled = false; return; } - const fd = new FormData(); - fd.append("file", pendingFile); - if (pendingArtist) fd.append("artist", pendingArtist); - if (pendingTitle) fd.append("title", pendingTitle); - - showLive("Nalaganje datoteke", `${pendingFile.name} (${(pendingFile.size / 1024 / 1024).toFixed(1)} MB)`, 0); - - const xhr = new XMLHttpRequest(); - xhr.upload.onprogress = e => { - if (e.lengthComputable) { - // Upload je 0–25% celotnega procesa - const uploadPct = (e.loaded / e.total) * 25; - const mbDone = (e.loaded / 1024 / 1024).toFixed(1); - const mbTotal = (e.total / 1024 / 1024).toFixed(1); - showLive( - "Nalaganje datoteke", - `${mbDone} / ${mbTotal} MB (${((e.loaded / e.total) * 100).toFixed(0)}%)`, - uploadPct - ); - } - }; - xhr.onload = async () => { - if (xhr.status !== 200) { - liveFail("Upload napaka: " + xhr.responseText); - $("#submit-btn").disabled = false; - return; - } - const job = JSON.parse(xhr.responseText); - showLive("Naloženo, začenjam obdelavo...", `Job ${job.id}`, 28); + + // Generate batch ID za skupinsko sledenje (Telegram summary) + const batchId = "batch-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); + const totalFiles = pendingFiles.length; + + // Upload + queue all files SEQUENTIALLY (1 hkrati za stabilnost) + for (let i = 0; i < pendingFiles.length; i++) { + const item = pendingFiles[i]; + const f = item.file; + const sizeMB = (f.size / 1024 / 1024).toFixed(1); + + showLive( + `Nalaganje ${i + 1}/${totalFiles}`, + `${f.name} (${sizeMB} MB)`, + ((i / totalFiles) * 100).toFixed(0) + ); + + const fd = new FormData(); + fd.append("file", f); + if (item.artist) fd.append("artist", item.artist); + if (item.title) fd.append("title", item.title); + fd.append("batch_id", batchId); + try { - const proc = await fetch("/api/process", { + const uploadResp = await uploadFileXHR(fd, (loaded, total) => { + const filePct = (loaded / total) * 100; + showLive( + `Nalaganje ${i + 1}/${totalFiles}`, + `${f.name} — ${(loaded/1024/1024).toFixed(1)}/${sizeMB} MB (${filePct.toFixed(0)}%)`, + ((i + filePct/100) / totalFiles) * 100 + ); + }); + const job = JSON.parse(uploadResp); + + // Pošlji "process" da se postavi v queue + await fetch("/api/process", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ job_id: job.id, ...settings }), + body: JSON.stringify({ job_id: job.id, batch_id: batchId, ...settings }), }); - if (!proc.ok) { - liveFail("Process start napaka: " + await proc.text()); - return; - } - watchJob(job.id); - refreshJobs(); } catch (e) { - liveFail("Process napaka: " + e.message); + console.error(`Failed to upload ${f.name}: ${e.message}`); + liveFail(`Napaka pri ${f.name}: ${e.message}`); + // Continue z naslednjimi } - }; - xhr.onerror = () => liveFail("Upload prekinjen — preveri internet povezavo"); - xhr.upload.onerror = () => liveFail("Upload napaka pri prenosu"); - xhr.open("POST", "/api/upload"); - xhr.send(fd); + } + + // Vsi naloženi + showLive( + "✅ Vse v vrsti za obdelavo", + `${totalFiles} datotek se obdeluje zaporedno · obvestilo na Telegram`, + 100 + ); + pendingFiles = []; + renderFileQueue(); + refreshJobs(); } } catch (e) { liveFail(e.message); } finally { - // Submit button enable tudi če napaka, da lahko ponovno poskusi setTimeout(() => { $("#submit-btn").disabled = false; }, 500); } }); + + // XHR helper z progress callback (vrne text response) + function uploadFileXHR(formData, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.upload.onprogress = e => { + if (e.lengthComputable && onProgress) { + onProgress(e.loaded, e.total); + } + }; + xhr.onload = () => { + if (xhr.status === 200) resolve(xhr.responseText); + else reject(new Error(`HTTP ${xhr.status}: ${xhr.responseText.slice(0, 200)}`)); + }; + xhr.onerror = () => reject(new Error("Upload error")); + xhr.upload.onerror = () => reject(new Error("Upload transfer error")); + xhr.open("POST", "/api/upload"); + xhr.send(formData); + }); + } // ─── Watch job (SSE) ──────────────────────────── function watchJob(jobId) { @@ -786,10 +846,6 @@ if (job.status === "done") { liveDone(jobId); $("#submit-btn").disabled = false; - // Reset upload form - pendingFile = null; - fileInput.value = ""; - dz.querySelector("div").textContent = "Klikni ali povleci video sem"; } else if (job.status === "failed") { liveFail(job.error || "Obdelava ni uspela"); $("#submit-btn").disabled = false;