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:
parent
157e6b781e
commit
91cc03658d
134
app/main.py
134
app/main.py
@ -511,14 +511,62 @@ def process_job(job_id):
|
|||||||
output_path=str(output_path),
|
output_path=str(output_path),
|
||||||
output_size_mb=round(output_path.stat().st_size / 1024 / 1024, 2),
|
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:
|
else:
|
||||||
update_job(
|
update_job(
|
||||||
job_id,
|
job_id,
|
||||||
status="failed",
|
status="failed",
|
||||||
error="Output datoteka ne obstaja po obdelavi",
|
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:
|
except Exception as e:
|
||||||
update_job(job_id, status="failed", error=str(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")
|
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")
|
@app.on_event("startup")
|
||||||
async def resume_or_cleanup_jobs():
|
async def resume_or_cleanup_jobs():
|
||||||
"""Ob startu containerja: avto-resume processing jobs ali jih označi kot error.
|
"""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" ⚠️ Napaka pri {f.name}: {e}")
|
||||||
|
|
||||||
print(f" ✅ Resumed: {resumed_count}, Error: {error_count}")
|
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):
|
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)
|
llm_model: Optional[str] = None # specifičen model (privzeto najboljši za provider)
|
||||||
# STT provider (Scribe je 18x hitreje + boljša multilingual accuracy)
|
# STT provider (Scribe je 18x hitreje + boljša multilingual accuracy)
|
||||||
whisper_provider: str = "auto" # auto / elevenlabs / local
|
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(...),
|
file: UploadFile = File(...),
|
||||||
artist: Optional[str] = Form(None),
|
artist: Optional[str] = Form(None),
|
||||||
title: Optional[str] = Form(None),
|
title: Optional[str] = Form(None),
|
||||||
|
batch_id: Optional[str] = Form(None),
|
||||||
user: str = Depends(check_auth),
|
user: str = Depends(check_auth),
|
||||||
):
|
):
|
||||||
if not file.filename:
|
if not file.filename:
|
||||||
@ -692,6 +817,9 @@ async def upload_video(
|
|||||||
"updated_at": time.time(),
|
"updated_at": time.time(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if batch_id:
|
||||||
|
job["batch_id"] = batch_id
|
||||||
|
|
||||||
# Artist + title — najprej user-provided, potem parse iz filename
|
# Artist + title — najprej user-provided, potem parse iz filename
|
||||||
if artist and title:
|
if artist and title:
|
||||||
# User je vpisal ali potrdil
|
# User je vpisal ali potrdil
|
||||||
@ -740,7 +868,7 @@ async def submit_youtube(
|
|||||||
"quality": payload.quality,
|
"quality": payload.quality,
|
||||||
}
|
}
|
||||||
save_job(job)
|
save_job(job)
|
||||||
background.add_task(process_job, job_id)
|
# Queue worker bo pograbil
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
@ -775,12 +903,14 @@ async def start_processing(
|
|||||||
llm_provider=payload.llm_provider,
|
llm_provider=payload.llm_provider,
|
||||||
llm_model=payload.llm_model,
|
llm_model=payload.llm_model,
|
||||||
whisper_provider=payload.whisper_provider,
|
whisper_provider=payload.whisper_provider,
|
||||||
|
batch_id=payload.batch_id,
|
||||||
current_step="V vrsti za obdelavo",
|
current_step="V vrsti za obdelavo",
|
||||||
# Počisti pretekle napake (retry-friendly)
|
# Počisti pretekle napake (retry-friendly)
|
||||||
chorus_error=None,
|
chorus_error=None,
|
||||||
interrupted_at=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)
|
return load_job(payload.job_id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
119
app/telegram.py
Normal file
119
app/telegram.py
Normal file
@ -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)
|
||||||
@ -1429,31 +1429,59 @@ def main():
|
|||||||
clip_range["reason"] += f" (start extended back)"
|
clip_range["reason"] += f" (start extended back)"
|
||||||
|
|
||||||
# Najdi vse segmente ki se začnejo PO trenutnem clip end
|
# 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:
|
for seg in corrected_segs:
|
||||||
seg_start = float(seg.get("start", 0))
|
seg_start = float(seg.get("start", 0))
|
||||||
seg_end = float(seg.get("end", 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:
|
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 <= soft_extension_limit:
|
||||||
if seg_end > current_end and seg_end <= extension_limit:
|
new_end = min(seg_end + 0.3, soft_extension_limit)
|
||||||
# Podaljšaj clip do konca tega segmenta + 0.3s diha
|
|
||||||
new_end = min(seg_end + 0.3, extension_limit)
|
|
||||||
if new_end > current_end:
|
if new_end > current_end:
|
||||||
print(f" 🎵 Podaljšam clip {current_end:.1f}s → {new_end:.1f}s "
|
print(f" 🎵 Podaljšam clip {current_end:.1f}s → {new_end:.1f}s "
|
||||||
f"(zadnji segment refrena se zaključi)", file=sys.stderr)
|
f"(zadnji segment refrena se zaključi)", file=sys.stderr)
|
||||||
current_end = new_end
|
current_end = new_end
|
||||||
else:
|
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
|
pause = seg_start - current_end
|
||||||
if pause < 1.0 and seg_end <= extension_limit:
|
|
||||||
# Še vedno povezano s clipom (kratko pavzo, naprej outro/echo)
|
# Predaleč → ustavi se
|
||||||
new_end = min(seg_end + 0.3, extension_limit)
|
if pause >= 0.7:
|
||||||
if new_end > current_end:
|
break
|
||||||
print(f" 🎵 Podaljšam clip {current_end:.1f}s → {new_end:.1f}s "
|
# Predolg segment = nov verz/refren, ne dodaj
|
||||||
f"(outro segment z {pause:.1f}s pavzo)", file=sys.stderr)
|
if (seg_end - seg_start) > 2.5:
|
||||||
current_end = new_end
|
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:
|
else:
|
||||||
# Daljša pavza — ustavi se tu
|
|
||||||
break
|
break
|
||||||
|
|
||||||
if current_end > clip_range["end"]:
|
if current_end > clip_range["end"]:
|
||||||
|
|||||||
@ -332,8 +332,8 @@
|
|||||||
<polyline points="17 8 12 3 7 8"/>
|
<polyline points="17 8 12 3 7 8"/>
|
||||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div>Klikni ali povleci video sem</div>
|
<div class="dz-text">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="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">
|
<input type="file" id="file-input" accept="video/*,.mxf,.mpg,.mpeg,.ts,.m2ts,.mts" multiple style="display:none">
|
||||||
</div>
|
</div>
|
||||||
<div id="file-queue" class="file-queue"></div>
|
<div id="file-queue" class="file-queue"></div>
|
||||||
@ -487,23 +487,23 @@
|
|||||||
// ─── Drag & drop ────────────────────────────────
|
// ─── Drag & drop ────────────────────────────────
|
||||||
const dz = $("#dropzone");
|
const dz = $("#dropzone");
|
||||||
const fileInput = $("#file-input");
|
const fileInput = $("#file-input");
|
||||||
let pendingFile = null;
|
let pendingFiles = []; // array namesto single file
|
||||||
let pendingArtist = null;
|
|
||||||
let pendingTitle = null;
|
|
||||||
|
|
||||||
dz.addEventListener("click", () => fileInput.click());
|
dz.addEventListener("click", () => fileInput.click());
|
||||||
fileInput.addEventListener("change", () => {
|
fileInput.addEventListener("change", () => {
|
||||||
if (fileInput.files[0]) {
|
if (fileInput.files.length > 0) {
|
||||||
handleFileSelected(fileInput.files[0]);
|
addFilesToQueue([...fileInput.files]);
|
||||||
}
|
}
|
||||||
|
fileInput.value = "";
|
||||||
});
|
});
|
||||||
["dragover", "dragenter"].forEach(ev =>
|
["dragover", "dragenter"].forEach(ev =>
|
||||||
dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add("drag"); }));
|
dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.add("drag"); }));
|
||||||
["dragleave", "drop"].forEach(ev =>
|
["dragleave", "drop"].forEach(ev =>
|
||||||
dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove("drag"); }));
|
dz.addEventListener(ev, e => { e.preventDefault(); dz.classList.remove("drag"); }));
|
||||||
dz.addEventListener("drop", e => {
|
dz.addEventListener("drop", e => {
|
||||||
const f = e.dataTransfer.files[0];
|
if (e.dataTransfer.files.length > 0) {
|
||||||
if (f) handleFileSelected(f);
|
addFilesToQueue([...e.dataTransfer.files]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Klient-side parser (mora ustrezati backend parse_artist_title)
|
// Klient-side parser (mora ustrezati backend parse_artist_title)
|
||||||
@ -542,25 +542,59 @@
|
|||||||
return [null, null];
|
return [null, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileSelected(f) {
|
function addFilesToQueue(files) {
|
||||||
const [artist, title] = parseArtistTitle(f.name);
|
for (const f of files) {
|
||||||
|
const [artist, title] = parseArtistTitle(f.name);
|
||||||
pendingFile = f;
|
pendingFiles.push({ file: f, artist, title });
|
||||||
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>`;
|
|
||||||
}
|
}
|
||||||
|
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 ─────────────────────────
|
// ─── Settings collector ─────────────────────────
|
||||||
@ -704,68 +738,94 @@
|
|||||||
watchJob(job.id);
|
watchJob(job.id);
|
||||||
refreshJobs();
|
refreshJobs();
|
||||||
} else {
|
} else {
|
||||||
if (!pendingFile) {
|
if (pendingFiles.length === 0) {
|
||||||
alert("Izberi datoteko");
|
alert("Izberi vsaj eno datoteko");
|
||||||
$("#submit-btn").disabled = false;
|
$("#submit-btn").disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fd = new FormData();
|
|
||||||
fd.append("file", pendingFile);
|
// Generate batch ID za skupinsko sledenje (Telegram summary)
|
||||||
if (pendingArtist) fd.append("artist", pendingArtist);
|
const batchId = "batch-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
|
||||||
if (pendingTitle) fd.append("title", pendingTitle);
|
const totalFiles = pendingFiles.length;
|
||||||
|
|
||||||
showLive("Nalaganje datoteke", `${pendingFile.name} (${(pendingFile.size / 1024 / 1024).toFixed(1)} MB)`, 0);
|
// Upload + queue all files SEQUENTIALLY (1 hkrati za stabilnost)
|
||||||
|
for (let i = 0; i < pendingFiles.length; i++) {
|
||||||
const xhr = new XMLHttpRequest();
|
const item = pendingFiles[i];
|
||||||
xhr.upload.onprogress = e => {
|
const f = item.file;
|
||||||
if (e.lengthComputable) {
|
const sizeMB = (f.size / 1024 / 1024).toFixed(1);
|
||||||
// Upload je 0–25% celotnega procesa
|
|
||||||
const uploadPct = (e.loaded / e.total) * 25;
|
showLive(
|
||||||
const mbDone = (e.loaded / 1024 / 1024).toFixed(1);
|
`Nalaganje ${i + 1}/${totalFiles}`,
|
||||||
const mbTotal = (e.total / 1024 / 1024).toFixed(1);
|
`${f.name} (${sizeMB} MB)`,
|
||||||
showLive(
|
((i / totalFiles) * 100).toFixed(0)
|
||||||
"Nalaganje datoteke",
|
);
|
||||||
`${mbDone} / ${mbTotal} MB (${((e.loaded / e.total) * 100).toFixed(0)}%)`,
|
|
||||||
uploadPct
|
const fd = new FormData();
|
||||||
);
|
fd.append("file", f);
|
||||||
}
|
if (item.artist) fd.append("artist", item.artist);
|
||||||
};
|
if (item.title) fd.append("title", item.title);
|
||||||
xhr.onload = async () => {
|
fd.append("batch_id", batchId);
|
||||||
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);
|
|
||||||
try {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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) {
|
} 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");
|
// Vsi naloženi
|
||||||
xhr.open("POST", "/api/upload");
|
showLive(
|
||||||
xhr.send(fd);
|
"✅ Vse v vrsti za obdelavo",
|
||||||
|
`${totalFiles} datotek se obdeluje zaporedno · obvestilo na Telegram`,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
pendingFiles = [];
|
||||||
|
renderFileQueue();
|
||||||
|
refreshJobs();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
liveFail(e.message);
|
liveFail(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
// Submit button enable tudi če napaka, da lahko ponovno poskusi
|
|
||||||
setTimeout(() => { $("#submit-btn").disabled = false; }, 500);
|
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) ────────────────────────────
|
// ─── Watch job (SSE) ────────────────────────────
|
||||||
function watchJob(jobId) {
|
function watchJob(jobId) {
|
||||||
@ -786,10 +846,6 @@
|
|||||||
if (job.status === "done") {
|
if (job.status === "done") {
|
||||||
liveDone(jobId);
|
liveDone(jobId);
|
||||||
$("#submit-btn").disabled = false;
|
$("#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") {
|
} else if (job.status === "failed") {
|
||||||
liveFail(job.error || "Obdelava ni uspela");
|
liveFail(job.error || "Obdelava ni uspela");
|
||||||
$("#submit-btn").disabled = false;
|
$("#submit-btn").disabled = false;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user