reels-app/templates/index.html
Sebastjan Artič 91cc03658d Multi-upload batch queue + Telegram notifications
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: <name> + 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
2026-04-29 15:12:38 +00:00

1050 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="sl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reels Clipper · biba.live</title>
<style>
:root {
--bg: #0d0e12;
--panel: #1a1c24;
--panel-2: #232631;
--border: #2d3142;
--text: #e6e8ed;
--muted: #8a8fa3;
--accent: #DC1C4C;
--accent-2: #ff3a6e;
--success: #3ec98f;
--warn: #f0b03b;
--error: #ef4444;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.5;
}
header {
padding: 24px 32px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 16px;
}
header h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.3px;
}
.accent-mark {
display: inline-block;
background: var(--accent);
padding: 2px 8px;
border-radius: 4px;
font-weight: 800;
color: white;
margin-right: 4px;
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 32px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: start;
}
main > section.card:first-of-type {
position: sticky;
top: 16px;
max-height: calc(100vh - 32px);
overflow-y: auto;
}
@media (max-width: 800px) {
main { grid-template-columns: 1fr; }
main > section.card:first-of-type {
position: static;
max-height: none;
}
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.card h2 {
margin: 0 0 16px;
font-size: 16px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
}
.dropzone {
border: 2px dashed var(--border);
border-radius: 10px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.15s ease;
}
.dropzone:hover, .dropzone.drag {
border-color: var(--accent);
background: rgba(220, 28, 76, 0.05);
}
.dropzone svg { width: 48px; height: 48px; opacity: 0.5; margin-bottom: 8px; }
.dropzone .small { color: var(--muted); font-size: 13px; }
input[type="text"], input[type="url"], select, input[type="number"] {
width: 100%;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
color: var(--text);
font-size: 14px;
font-family: inherit;
}
input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; margin-top: 12px; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
button {
background: var(--accent);
color: white;
border: none;
padding: 11px 20px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: background 0.15s;
}
button:hover { background: var(--accent-2); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
button.ghost:hover { background: var(--panel-2); color: var(--text); }
button.small { padding: 6px 12px; font-size: 12px; }
.full-width { grid-column: 1 / -1; }
.jobs-list { display: flex; flex-direction: column; gap: 10px; }
.job {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.job-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.job-title { font-weight: 600; font-size: 14px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.badge { padding: 3px 10px; border-radius: 99px; font-size: 11px; font-weight: 600; }
.badge.queued { background: rgba(138, 143, 163, 0.15); color: var(--muted); }
.badge.processing, .badge.downloading { background: rgba(240, 176, 59, 0.15); color: var(--warn); }
.badge.done { background: rgba(62, 201, 143, 0.15); color: var(--success); }
.badge.failed { background: rgba(239, 68, 68, 0.15); color: var(--error); }
.badge.uploaded { background: rgba(220, 28, 76, 0.15); color: var(--accent); }
.progress { height: 4px; background: var(--border); border-radius: 99px; overflow: hidden; }
.progress-bar { height: 100%; background: var(--accent); width: 0%; transition: width 0.3s; }
.progress-bar.indeterminate {
width: 30%;
animation: shimmer 1.5s linear infinite;
}
@keyframes shimmer {
0% { margin-left: -30%; }
100% { margin-left: 100%; }
}
.step { font-size: 12px; color: var(--muted); }
.meta { font-size: 11px; color: var(--muted); display: flex; gap: 12px; flex-wrap: wrap; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }
.error-text { color: var(--error); font-size: 12px; }
video { width: 100%; max-height: 400px; border-radius: 8px; background: black; }
.empty { color: var(--muted); text-align: center; padding: 40px 20px; font-size: 14px; }
.toggle { display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; font-size: 13px; }
.toggle input { width: auto; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
.tab { padding: 10px 14px; cursor: pointer; color: var(--muted); border-bottom: 2px solid transparent; font-size: 14px; }
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
.hidden { display: none !important; }
code { background: var(--panel-2); padding: 1px 6px; border-radius: 3px; font-family: ui-monospace, monospace; font-size: 12px; }
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
.progress-bar.smooth { transition: width 0.4s ease; }
/* ─── Video preview modal ─── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
backdrop-filter: blur(4px);
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.modal-content {
position: relative;
max-width: 95vw;
max-height: 95vh;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.modal-content video {
max-width: 100%;
max-height: 85vh;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
background: black;
}
.modal-title {
color: #fff;
font-weight: 600;
text-align: center;
max-width: 600px;
padding: 0 12px;
font-size: 14px;
}
.modal-close {
position: absolute;
top: -8px;
right: -8px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent);
color: white;
border: none;
font-size: 20px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
transition: transform 0.15s ease;
z-index: 1;
}
.modal-close:hover { transform: scale(1.1); background: var(--accent-2); }
.modal-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.modal-actions button {
padding: 10px 18px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
font-weight: 500;
cursor: pointer;
font-size: 14px;
}
.modal-actions button.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.modal-actions button:hover { background: var(--panel-2); }
.modal-actions button.primary:hover { background: var(--accent-2); }
/* ─── Multi-file queue ─── */
.file-queue {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.file-queue-item {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.file-queue-item .name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-queue-item .name b { color: var(--accent-2); }
.file-queue-item .size {
color: var(--muted);
font-size: 11px;
flex-shrink: 0;
}
.file-queue-item .remove {
background: transparent;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 16px;
padding: 0 4px;
line-height: 1;
}
.file-queue-item .remove:hover { color: var(--error); }
.file-queue-item .warn {
color: var(--warn);
font-size: 10px;
}
</style>
</head>
<body>
<header>
<h1><span class="accent-mark">1]</span> reels clipper</h1>
<span style="color: var(--muted); font-size: 13px;">biba.live</span>
</header>
<main>
<!-- ─── INPUT ───────────────────────────────────── -->
<section class="card">
<h2>nov reel</h2>
<div class="tabs">
<div class="tab active" data-tab="upload">Upload</div>
<div class="tab" data-tab="youtube">YouTube</div>
</div>
<div id="tab-upload">
<div class="dropzone" id="dropzone">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<div class="dz-text">Klikni ali povleci video sem</div>
<div class="small dz-hint">.mp4, .mov, .webm, .mxf, .mpg — do 10 GB · <b>Lahko izberete več datotek hkrati</b></div>
<input type="file" id="file-input" accept="video/*,.mxf,.mpg,.mpeg,.ts,.m2ts,.mts" multiple style="display:none">
</div>
<div id="file-queue" class="file-queue"></div>
</div>
<div id="tab-youtube" class="hidden">
<label>YouTube URL</label>
<input type="url" id="yt-url" placeholder="https://www.youtube.com/watch?v=...">
</div>
<label>Način reframe</label>
<select id="mode">
<option value="track">Track (sledi obrazu — intervjuji, vlogi)</option>
<option value="center">Center (statična kamera)</option>
<option value="blur">Blur (glasba, koncerti)</option>
</select>
<!-- Skrita polja: jezik in model sta avto. Vrednosti uporabljene v JS submit. -->
<input type="hidden" id="lang" value="">
<input type="hidden" id="model" value="large-v3">
<div style="font-size: 12px; color: var(--text-dim); margin-top: 8px;">
🤖 Jezik: avtomatsko zaznan (Whisper, 3-sample voting) · Model: medium · LLM analiza: Claude
</div>
<label class="toggle" style="margin-top: 16px;">
<input type="checkbox" id="auto-chorus" checked>
Pametna izbira odseka (Whisper + energy → najde refren)
</label>
<div style="font-size: 12px; color: var(--text-dim); margin-top: 4px; margin-left: 26px;">
Sistem naredi <b>celoten transkript</b> in <b>energy profile</b>, najde refren in ga izreže.
Audio fade in/out je avtomatsko dodan na meje vokala.
</div>
<label class="toggle" style="margin-top: 12px; margin-left: 26px;">
<input type="checkbox" id="include-prebuild">
Vključi pre-chorus (build-up pred refrenom)
</label>
<div style="font-size: 12px; color: var(--text-dim); margin-top: 2px; margin-left: 52px;">
Privzeto izklopljeno: dobiš čist refren brez kitice.
</div>
<div id="manual-times" class="row hidden">
<div>
<label>Začetek (sekunde ali mm:ss)</label>
<input type="text" id="start" placeholder="npr. 1:24">
</div>
<div>
<label>Trajanje (s)</label>
<input type="number" id="duration" value="30" min="5" max="180">
</div>
</div>
<div class="row">
<div>
<label>Stil podnapisov</label>
<select id="subtitle-style">
<option value="reels">Reels (TikTok beli)</option>
<option value="yellow">Yellow (MrBeast)</option>
<option value="minimal">Minimal</option>
</select>
</div>
<div>
<label>Kvaliteta</label>
<select id="quality">
<option value="fast">Fast (preview)</option>
<option value="medium" selected>Medium (objava)</option>
<option value="high">High (arhiv)</option>
</select>
</div>
</div>
<div class="row" style="margin-top: 12px;">
<div>
<label>AI za analizo (popravlja transkript + razume strukturo)</label>
<select id="llm-provider">
<option value="claude" selected>Claude Sonnet 4.6 (priporočeno)</option>
<option value="gemini">Gemini 3.1 Pro (multilingual)</option>
<option value="auto">Auto (Claude → Gemini fallback)</option>
</select>
</div>
</div>
<label class="toggle" style="margin-top: 12px;">
<input type="checkbox" id="no-subs">
Brez podnapisov
</label>
<button id="submit-btn" class="full-width" style="margin-top: 20px; width: 100%;">
Naredi reel
</button>
<!-- Live progress panel pod upload formo -->
<div id="live-progress" class="hidden" style="margin-top: 20px; padding: 14px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px;">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
<div class="spinner" id="live-spinner"></div>
<div style="font-weight: 600; font-size: 14px;" id="live-stage">Nalaganje...</div>
<span class="badge processing" id="live-badge" style="margin-left: auto;">aktivno</span>
</div>
<div class="progress" style="margin: 8px 0;">
<div class="progress-bar" id="live-bar" style="width: 0%;"></div>
</div>
<div style="font-size: 12px; color: var(--muted);" id="live-detail">Pripravljam...</div>
<!-- Analysis summary z izbranim odsekom in transkriptom -->
<div id="live-analysis" class="hidden" style="margin-top: 12px; padding: 10px; background: var(--panel); border-radius: 6px; font-size: 12px;">
<div id="live-analysis-summary" style="margin-bottom: 8px; color: var(--text-dim);"></div>
<details style="margin-top: 6px;">
<summary style="cursor: pointer; color: var(--accent); font-weight: 600;">Pokaži celoten transkript</summary>
<div id="live-transcript" style="margin-top: 8px; max-height: 240px; overflow-y: auto; font-family: monospace; font-size: 11px; line-height: 1.6;"></div>
</details>
</div>
<div id="live-result" class="hidden" style="margin-top: 12px; display: flex; gap: 8px;">
<button class="small" id="live-download" style="display: none;">⬇ Download</button>
<button class="small ghost" id="live-preview" style="display: none;">▶ Preview</button>
</div>
<video id="live-video" class="hidden" controls style="margin-top: 12px; max-height: 400px; width: 100%; border-radius: 8px; background: black;"></video>
</div>
</section>
<!-- ─── JOBS ────────────────────────────────────── -->
<section class="card">
<h2>moji reels</h2>
<div class="jobs-list" id="jobs-list">
<div class="empty">Še ni obdelav</div>
</div>
</section>
</main>
<script>
const $ = (s) => document.querySelector(s);
const $$ = (s) => document.querySelectorAll(s);
// ─── Tabs ───────────────────────────────────────
$$(".tab").forEach(t => {
t.addEventListener("click", () => {
$$(".tab").forEach(x => x.classList.remove("active"));
t.classList.add("active");
const target = t.dataset.tab;
$("#tab-upload").classList.toggle("hidden", target !== "upload");
$("#tab-youtube").classList.toggle("hidden", target !== "youtube");
});
});
// ─── Auto-chorus toggle ─────────────────────────
$("#auto-chorus").addEventListener("change", e => {
$("#manual-times").classList.toggle("hidden", e.target.checked);
});
// Jezik in model sta avto — skritja polja, ne potrebujemo listenerjev.
// ─── Drag & drop ────────────────────────────────
const dz = $("#dropzone");
const fileInput = $("#file-input");
let pendingFiles = []; // array namesto single file
dz.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
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 => {
if (e.dataTransfer.files.length > 0) {
addFilesToQueue([...e.dataTransfer.files]);
}
});
// Klient-side parser (mora ustrezati backend parse_artist_title)
function parseArtistTitle(filename) {
if (!filename) return [null, null];
let name = filename.replace(/\.[^.]+$/, ""); // remove ext
// Odstrani noise
const noise = [
/\(Official\s+(?:Music\s+)?Video\)/gi,
/\(Officia[lk]\s+Audio\)/gi,
/\(Offizielles\s+(?:Musik)?[Vv]ideo\)/gi,
/\(Lyric[s]?\s+Video\)/gi,
/\(Audio\)/gi,
/\(HD\)|\(HQ\)|\(4K\)/gi,
/\(Live\)|\(Remix\)|\(Remaster(?:ed)?\s*\d{0,4}\)/gi,
/\[Official.*?\]|\[Music.*?\]|\[Audio.*?\]/gi,
/\bofficial\s+video\b|\bofficial\s+audio\b/gi,
/\boriginal\s+(?:video|audio)\b/gi,
/\bMV\b|\b4K\b|\bHD\b|\bHQ\b/g,
];
for (const r of noise) name = name.replace(r, "");
name = name.replace(/\s+/g, " ").trim();
// Probaj separatorje
for (const sep of [" - ", " ", " — ", " | ", " : "]) {
if (name.includes(sep)) {
const parts = name.split(sep);
if (parts.length >= 2) {
const artist = parts[0].trim().replace(/^[\s\-–—|.:_]+|[\s\-–—|.:_]+$/g, "");
const title = parts.slice(1).join(sep).trim().replace(/^[\s\-–—|.:_]+|[\s\-–—|.:_]+$/g, "");
if (artist && title) return [artist, title];
}
}
}
return [null, null];
}
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 · <b>Lahko izberete več datotek hkrati</b>";
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 = `<b>${escapeHtml(item.artist)}${escapeHtml(item.title)}</b>` +
`<div style="font-size:10px;color:var(--muted)">${escapeHtml(item.file.name)}</div>`;
} else {
nameHtml = `${escapeHtml(item.file.name)}` +
`<div class="warn">⚠ Brez razvidnega imena — ACR bo poskusil prepoznati</div>`;
}
div.innerHTML = `
<div class="name">${nameHtml}</div>
<div class="size">${sizeMB} MB</div>
<button class="remove" data-idx="${idx}" title="Odstrani">×</button>
`;
q.appendChild(div);
});
q.querySelectorAll(".remove").forEach(btn => {
btn.addEventListener("click", () => removeFromQueue(parseInt(btn.dataset.idx)));
});
}
// ─── Settings collector ─────────────────────────
function collectSettings() {
const auto = $("#auto-chorus").checked;
const duration = parseFloat($("#duration").value) || 30;
return {
mode: $("#mode").value,
lang: $("#lang").value || null,
whisper_model: $("#model").value,
auto_chorus: auto,
include_prebuild: $("#include-prebuild").checked,
start: !auto && $("#start").value ? parseTimestamp($("#start").value) : null,
duration: duration,
max_duration: auto ? Math.round(duration * 1.5) : duration,
min_duration: auto ? Math.round(duration * 0.7) : duration,
subtitle_style: $("#subtitle-style").value,
quality: $("#quality").value,
no_subs: $("#no-subs").checked,
llm_provider: $("#llm-provider").value,
};
}
function parseTimestamp(s) {
s = s.trim();
if (s.includes(":")) {
const parts = s.split(":").map(parseFloat);
if (parts.length === 2) return parts[0] * 60 + parts[1];
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
return parseFloat(s);
}
// ─── Live progress panel ────────────────────────
const livePanel = $("#live-progress");
const liveStage = $("#live-stage");
const liveDetail = $("#live-detail");
const liveBar = $("#live-bar");
const liveBadge = $("#live-badge");
const liveSpinner = $("#live-spinner");
const liveResult = $("#live-result");
const liveVideo = $("#live-video");
const liveDownloadBtn = $("#live-download");
const livePreviewBtn = $("#live-preview");
function showLive(stage, detail, pct, badge = "aktivno", badgeClass = "processing") {
livePanel.classList.remove("hidden");
liveStage.textContent = stage;
liveDetail.textContent = detail || "";
liveBadge.textContent = badge;
liveBadge.className = "badge " + badgeClass;
if (pct === null || pct === undefined) {
// indeterminate
liveBar.classList.add("indeterminate");
liveBar.classList.remove("smooth");
liveBar.style.width = "30%";
} else {
liveBar.classList.remove("indeterminate");
liveBar.classList.add("smooth");
liveBar.style.width = Math.max(0, Math.min(100, pct)) + "%";
}
}
function liveDone(jobId) {
liveSpinner.style.display = "none";
liveBar.classList.remove("indeterminate");
liveBar.classList.add("smooth");
liveBar.style.width = "100%";
liveStage.textContent = "✅ Končano";
liveBadge.textContent = "končano";
liveBadge.className = "badge done";
liveDetail.textContent = "Reel je pripravljen za prenos ali predogled.";
liveResult.classList.remove("hidden");
liveDownloadBtn.style.display = "inline-block";
livePreviewBtn.style.display = "inline-block";
liveDownloadBtn.onclick = () => window.open(`/api/download/${jobId}`);
livePreviewBtn.onclick = () => {
liveVideo.src = `/api/preview/${jobId}`;
liveVideo.classList.remove("hidden");
liveVideo.play();
};
}
function liveFail(error) {
liveSpinner.style.display = "none";
liveBar.classList.remove("indeterminate");
liveBar.style.width = "100%";
liveBar.style.background = "var(--error)";
liveStage.textContent = "❌ Napaka";
liveBadge.textContent = "napaka";
liveBadge.className = "badge failed";
liveDetail.innerHTML = `<span style="color: var(--error)">${error || "Neznana napaka"}</span>`;
}
function liveReset() {
livePanel.classList.add("hidden");
liveSpinner.style.display = "block";
liveBar.style.background = "";
liveResult.classList.add("hidden");
liveDownloadBtn.style.display = "none";
livePreviewBtn.style.display = "none";
liveVideo.classList.add("hidden");
liveVideo.src = "";
}
// Stage label mapping (server step → friendly slo + percent estimate)
const STAGE_INFO = {
"Naloženo, čaka na obdelavo": { pct: 30, friendly: "Naloženo, čaka v vrsti" },
"V vrsti za obdelavo": { pct: 35, friendly: "V vrsti za obdelavo" },
"V vrsti za YouTube prenos": { pct: 30, friendly: "V vrsti za YouTube prenos" },
"YouTube download": { pct: 45, friendly: "Prenašam z YouTube..." },
"Iščem refren (Whisper + energy)": { pct: 60, friendly: "Iščem refren v pesmi..." },
"Reframe + subtitles": { pct: 75, friendly: "Reframe v 9:16 + podnapisi..." },
"Končano": { pct: 100, friendly: "✅ Končano" },
};
// ─── Submit ─────────────────────────────────────
$("#submit-btn").addEventListener("click", async () => {
const isYT = $("#tab-youtube").classList.contains("hidden") === false;
const settings = collectSettings();
$("#submit-btn").disabled = true;
liveReset();
try {
if (isYT) {
const url = $("#yt-url").value.trim();
if (!url) { alert("Vpiši YouTube URL"); $("#submit-btn").disabled = false; return; }
showLive("Pošiljam YouTube job...", url, null);
const r = await fetch("/api/youtube", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, ...settings }),
});
if (!r.ok) {
const err = await r.text();
liveFail("YouTube submit napaka: " + err);
return;
}
const job = await r.json();
watchJob(job.id);
refreshJobs();
} else {
if (pendingFiles.length === 0) {
alert("Izberi vsaj eno datoteko");
$("#submit-btn").disabled = false;
return;
}
// 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 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, batch_id: batchId, ...settings }),
});
} catch (e) {
console.error(`Failed to upload ${f.name}: ${e.message}`);
liveFail(`Napaka pri ${f.name}: ${e.message}`);
// Continue z naslednjimi
}
}
// 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 {
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) {
const evt = new EventSource(`/api/stream/${jobId}`);
evt.onmessage = (e) => {
try {
const job = JSON.parse(e.data);
updateJobInList(job);
// Pokaži analysis summary in transkript če je na voljo
if (job.analysis_summary || job.full_transcript) {
updateAnalysisDisplay(job);
}
// Update live panel
const step = job.current_step || "";
const info = STAGE_INFO[step] || { pct: null, friendly: step };
if (job.status === "done") {
liveDone(jobId);
$("#submit-btn").disabled = false;
} else if (job.status === "failed") {
liveFail(job.error || "Obdelava ni uspela");
$("#submit-btn").disabled = false;
// Reset tudi po napaki, da naslednji upload dela
pendingFile = null;
fileInput.value = "";
dz.querySelector("div").textContent = "Klikni ali povleci video sem";
} else {
showLive(info.friendly, `Job ${job.id} · ${job.status}`, info.pct);
}
if (job.status === "done" || job.status === "failed") {
evt.close();
refreshJobs();
}
} catch (err) {
console.error("SSE parse err:", err);
}
};
evt.onerror = () => {
evt.close();
// SSE may close at end of stream — final fetch to confirm
fetch(`/api/jobs/${jobId}`).then(r => r.json()).then(job => {
if (job.status === "done") liveDone(jobId);
else if (job.status === "failed") liveFail(job.error || "");
}).catch(() => {});
};
}
// ─── Jobs list ──────────────────────────────────
async function refreshJobs() {
const r = await fetch("/api/jobs");
if (!r.ok) return;
const data = await r.json();
const list = $("#jobs-list");
if (!data.jobs.length) {
list.innerHTML = '<div class="empty">Še ni obdelav</div>';
return;
}
list.innerHTML = "";
data.jobs.forEach(j => list.appendChild(buildJobEl(j)));
// Watch any in-progress job
data.jobs.forEach(j => {
if (["queued", "processing", "downloading", "uploaded"].includes(j.status)) {
watchJob(j.id);
}
});
}
function updateJobInList(job) {
const existing = document.getElementById(`job-${job.id}`);
const el = buildJobEl(job);
if (existing) {
existing.replaceWith(el);
} else {
const list = $("#jobs-list");
if (list.querySelector(".empty")) list.innerHTML = "";
list.prepend(el);
}
}
function buildJobEl(job) {
const el = document.createElement("div");
el.className = "job";
el.id = `job-${job.id}`;
const title = job.source_type === "youtube"
? (job.youtube_url || "YouTube")
: (job.parsed_artist && job.parsed_title
? `${job.parsed_artist}${job.parsed_title}`
: (job.filename || job.id));
const sizeStr = job.output_size_mb ? `${job.output_size_mb} MB` :
job.size_mb ? `${job.size_mb} MB` : "";
const statusLabel = {
queued: "v vrsti", uploaded: "naloženo", processing: "obdeluje",
downloading: "prenaša", done: "končano", failed: "napaka",
}[job.status] || job.status;
const isProcessing = ["queued", "processing", "downloading"].includes(job.status);
const showBar = isProcessing ? '<div class="progress"><div class="progress-bar indeterminate"></div></div>' : "";
const actions = [];
if (job.status === "done") {
actions.push(`<button class="small" data-action="download" data-id="${job.id}">⬇ Download</button>`);
actions.push(`<button class="small ghost" data-action="preview" data-id="${job.id}">▶ Preview</button>`);
}
actions.push(`<button class="small ghost" data-action="delete" data-id="${job.id}">✕</button>`);
el.innerHTML = `
<div class="job-head">
<div class="job-title" title="${escapeHtml(title)}">${escapeHtml(title)}</div>
<span class="badge ${job.status}">${statusLabel}</span>
</div>
${job.current_step ? `<div class="step">${escapeHtml(job.current_step)}</div>` : ""}
${showBar}
${job.error ? `<div class="error-text">⚠ ${escapeHtml(job.error)}</div>` : ""}
<div class="meta">
<span>${job.source_type === "youtube" ? "YouTube" : "Upload"}</span>
${sizeStr ? `<span>${sizeStr}</span>` : ""}
${job.mode ? `<span>${job.mode}</span>` : ""}
${job.lang ? `<span>${job.lang}</span>` : ""}
</div>
<div class="actions">${actions.join("")}</div>
`;
// Shrani naslov za preview modal
el.dataset.title = title;
return el;
}
function escapeHtml(s) {
if (s == null) return "";
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
// Globalno delegirano poslušanje za action gumbe (Download / Preview / Delete)
document.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-action]");
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.id;
if (!id) return;
const card = btn.closest(".job");
const title = card?.dataset.title || "";
if (action === "download") {
window.open(`/api/download/${id}`);
} else if (action === "preview") {
previewJob(id, title);
} else if (action === "delete") {
deleteJob(id);
}
});
async function deleteJob(id) {
if (!confirm("Izbrišem ta job?")) return;
await fetch(`/api/jobs/${id}`, { method: "DELETE" });
refreshJobs();
}
function previewJob(id, title) {
// Odpre velik modal z reel videom
const overlay = document.createElement("div");
overlay.className = "modal-overlay";
overlay.innerHTML = `
<div class="modal-content" onclick="event.stopPropagation()">
<button class="modal-close" title="Zapri (ESC)">×</button>
<video src="/api/preview/${id}" controls autoplay playsinline></video>
${title ? `<div class="modal-title">${title}</div>` : ""}
<div class="modal-actions">
<button class="primary" onclick="window.open('/api/download/${id}')">⬇ Prenesi reel</button>
<button onclick="closeModal()">Zapri</button>
</div>
</div>
`;
const close = () => closeModal();
overlay.addEventListener("click", close);
overlay.querySelector(".modal-close").addEventListener("click", (e) => {
e.stopPropagation();
close();
});
// ESC key to close
const escHandler = (e) => {
if (e.key === "Escape") {
close();
document.removeEventListener("keydown", escHandler);
}
};
document.addEventListener("keydown", escHandler);
document.body.appendChild(overlay);
document.body.style.overflow = "hidden"; // prevent scroll behind modal
}
function closeModal() {
const overlay = document.querySelector(".modal-overlay");
if (overlay) {
// Stop video before removing (prevents memory leak)
const video = overlay.querySelector("video");
if (video) {
video.pause();
video.removeAttribute("src");
video.load();
}
overlay.remove();
document.body.style.overflow = "";
}
}
refreshJobs();
setInterval(refreshJobs, 10000);
</script>
</body>
</html>