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
This commit is contained in:
+133
-77
@@ -332,8 +332,8 @@
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<div>Klikni ali povleci video sem</div>
|
||||
<div class="small">.mp4, .mov, .webm, .mxf, .mpg — do 10 GB · <b>Lahko izberete več datotek hkrati</b></div>
|
||||
<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>
|
||||
@@ -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 =
|
||||
`📹 <b>${pendingArtist} — ${pendingTitle}</b>` +
|
||||
`<div style="font-size: 11px; color: var(--muted); margin-top: 4px;">${f.name} (${(f.size/1024/1024).toFixed(1)} MB)</div>`;
|
||||
} else {
|
||||
// Ni razvidno — pokaži opozorilo, ampak NE blokiraj (server bo poskusil ACR auto-recognize)
|
||||
dz.querySelector("div").innerHTML =
|
||||
`📹 ${f.name}` +
|
||||
`<div style="font-size: 11px; color: var(--warn); margin-top: 4px;">⚠ Iz imena ni razviden izvajalec — server bo poskusil avto-prepoznati pesem (ACRCloud)</div>` +
|
||||
`<div style="font-size: 11px; color: var(--muted); margin-top: 2px;">${(f.size/1024/1024).toFixed(1)} MB</div>`;
|
||||
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 ─────────────────────────
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user