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 @@