diff --git a/app/main.py b/app/main.py
index adb4bc7..b3e5693 100644
--- a/app/main.py
+++ b/app/main.py
@@ -54,6 +54,12 @@ AUTH_PASS = os.environ.get("AUTH_PASS", "change-me-in-coolify-env")
MAX_UPLOAD_MB = int(os.environ.get("MAX_UPLOAD_MB", "2000"))
+# Nextcloud upload (folxspeed/REELS/)
+NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "https://nextcloud.folx.tv")
+NEXTCLOUD_USER = os.environ.get("NEXTCLOUD_USER", "admin")
+NEXTCLOUD_PASS = os.environ.get("NEXTCLOUD_PASS", "")
+NEXTCLOUD_FOLDER = os.environ.get("NEXTCLOUD_FOLDER", "folxspeed/REELS")
+
# ────────────────────────────────────────────────────────────────
# Auth
@@ -1529,6 +1535,96 @@ class RecutRequest(BaseModel):
no_subs: Optional[bool] = None
+# ─── Nextcloud upload ─────────────────────────────────────────────
+NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "").rstrip("/")
+NEXTCLOUD_USER = os.environ.get("NEXTCLOUD_USER", "")
+NEXTCLOUD_PASS = os.environ.get("NEXTCLOUD_PASS", "")
+NEXTCLOUD_REELS_PATH = os.environ.get("NEXTCLOUD_REELS_PATH", "folxspeed/REELS")
+
+
+def _nextcloud_configured():
+ return bool(NEXTCLOUD_URL and NEXTCLOUD_USER and NEXTCLOUD_PASS)
+
+
+def _nextcloud_upload(local_path: str, remote_filename: str, target_subdir: str = None):
+ """Naloži datoteko na Nextcloud preko WebDAV (stdlib urllib).
+
+ Vrne (success: bool, url: str | error_msg: str).
+ target_subdir = opcijsko podmapo (default: NEXTCLOUD_REELS_PATH)
+ """
+ if not _nextcloud_configured():
+ return False, "Nextcloud ni konfiguriran (manjka NEXTCLOUD_URL/USER/PASS env)"
+
+ if not Path(local_path).exists():
+ return False, f"Datoteka ne obstaja: {local_path}"
+
+ import urllib.request, urllib.error, base64
+ from urllib.parse import quote
+
+ base_path = (target_subdir or NEXTCLOUD_REELS_PATH).strip("/")
+ safe_dir = "/".join(quote(p, safe="") for p in base_path.split("/"))
+ safe_file = quote(remote_filename, safe="")
+ url = f"{NEXTCLOUD_URL}/remote.php/dav/files/{NEXTCLOUD_USER}/{safe_dir}/{safe_file}"
+
+ auth = base64.b64encode(f"{NEXTCLOUD_USER}:{NEXTCLOUD_PASS}".encode()).decode()
+
+ try:
+ with open(local_path, "rb") as f:
+ data = f.read()
+ req = urllib.request.Request(
+ url,
+ data=data,
+ headers={
+ "Authorization": f"Basic {auth}",
+ "Content-Type": "video/mp4",
+ },
+ method="PUT",
+ )
+ with urllib.request.urlopen(req, timeout=120) as resp:
+ if resp.status in (200, 201, 204):
+ return True, url
+ return False, f"HTTP {resp.status}"
+ except urllib.error.HTTPError as e:
+ return False, f"HTTP {e.code}: {e.read().decode()[:200]}"
+ except Exception as e:
+ return False, f"Upload error: {e}"
+
+
+@app.post("/api/jobs/{job_id}/upload-nextcloud")
+async def upload_nextcloud(job_id: str, user: str = Depends(check_auth)):
+ """Naloži dokončan reel na Nextcloud /folxspeed/REELS/FOLX SLOVENIJA/."""
+ if not _nextcloud_configured():
+ raise HTTPException(500, "Nextcloud ni konfiguriran (manjka env vars)")
+
+ job = load_job(job_id)
+ if not job:
+ raise HTTPException(404, "Job ne obstaja")
+
+ if job.get("status") != "done":
+ raise HTTPException(400, f"Job ni done (status={job.get('status')})")
+
+ output_path = job.get("output_path")
+ if not output_path or not Path(output_path).exists():
+ raise HTTPException(404, "Output mp4 ne obstaja")
+
+ # Označim "uploading"
+ update_job(job_id, nextcloud_status="uploading", nextcloud_error=None)
+
+ # Lepo ime datoteke
+ download_name = build_download_filename(job)
+
+ print(f"☁️ Uploading {output_path} → Nextcloud as {download_name}", flush=True)
+ success, result = _nextcloud_upload(output_path, download_name)
+
+ if success:
+ update_job(job_id, nextcloud_status="uploaded", nextcloud_url=result, nextcloud_error=None)
+ print(f"☁️ Upload OK: {download_name}", flush=True)
+ return {"ok": True, "url": result, "filename": download_name}
+ else:
+ update_job(job_id, nextcloud_status="error", nextcloud_error=result)
+ raise HTTPException(500, f"Upload failed: {result}")
+
+
@app.post("/api/jobs/{job_id}/recut")
async def recut_job(job_id: str, payload: RecutRequest, user: str = Depends(check_auth)):
"""Re-rendar reel z user-defined timestampi (in opcijsko popravljenimi napisi).
@@ -1606,3 +1702,80 @@ async def recut_job(job_id: str, payload: RecutRequest, user: str = Depends(chec
"end": payload.end,
"duration": duration,
}
+
+
+# ────────────────────────────────────────────────────────────────
+# Nextcloud upload (folxspeed/REELS/)
+# ────────────────────────────────────────────────────────────────
+
+def _safe_filename_for_nextcloud(name: str) -> str:
+ """Sanitize filename za Nextcloud — odstrani problematične znake."""
+ import re
+ # Zamenjaj problematične znake z '_'
+ name = re.sub(r'[\\/:*?"<>|]', '_', name)
+ # Strip control chars
+ name = ''.join(c for c in name if c.isprintable())
+ return name.strip()[:200] or "reel.mp4"
+
+
+def upload_to_nextcloud(local_path: Path, remote_filename: str) -> tuple[bool, str]:
+ """Upload datoteko v Nextcloud preko WebDAV.
+
+ Vrne (success, message).
+ """
+ if not NEXTCLOUD_PASS:
+ return False, "NEXTCLOUD_PASS env var ni nastavljen"
+ if not local_path.exists():
+ return False, f"Lokalna datoteka ne obstaja: {local_path}"
+
+ safe_name = _safe_filename_for_nextcloud(remote_filename)
+ url = f"{NEXTCLOUD_URL.rstrip('/')}/remote.php/dav/files/{NEXTCLOUD_USER}/{NEXTCLOUD_FOLDER}/{safe_name}"
+
+ try:
+ import requests
+ with open(local_path, "rb") as f:
+ r = requests.put(
+ url,
+ data=f,
+ auth=(NEXTCLOUD_USER, NEXTCLOUD_PASS),
+ timeout=300, # 5 min za velike fajle
+ )
+ if r.status_code in (200, 201, 204):
+ return True, f"✅ Uploaded as {safe_name}"
+ return False, f"HTTP {r.status_code}: {r.text[:200]}"
+ except Exception as e:
+ return False, f"Upload error: {e}"
+
+
+@app.post("/api/jobs/{job_id}/upload-nextcloud")
+async def upload_job_to_nextcloud(job_id: str, user: str = Depends(check_auth)):
+ """Naloži output reel v Nextcloud /folxspeed/REELS/."""
+ job = load_job(job_id)
+ if not job:
+ raise HTTPException(404, "Ne obstaja")
+
+ if job.get("status") != "done":
+ raise HTTPException(400, "Reel še ni gotov (status != done)")
+
+ output_path = OUTPUT_DIR / f"{job_id}.mp4"
+ if not output_path.exists():
+ raise HTTPException(404, "Output mp4 ne obstaja")
+
+ # Naredi smiseln filename
+ download_name = build_download_filename(job) # npr. "FEHTARJI - GORENJSKA LJUBLJENA.mp4"
+
+ # Async background upload (ne čakaj v requestu — file je lahko 30 MB)
+ update_job(job_id, nextcloud_status="uploading")
+
+ def _do_upload():
+ success, msg = upload_to_nextcloud(output_path, download_name)
+ if success:
+ update_job(job_id, nextcloud_status="uploaded", nextcloud_filename=_safe_filename_for_nextcloud(download_name))
+ print(f"☁ Nextcloud upload OK: {job_id} → {download_name}", flush=True)
+ else:
+ update_job(job_id, nextcloud_status="failed", nextcloud_error=msg)
+ print(f"❌ Nextcloud upload failed: {job_id} → {msg}", flush=True)
+
+ threading.Thread(target=_do_upload, daemon=True).start()
+
+ return {"status": "uploading", "filename": _safe_filename_for_nextcloud(download_name)}
diff --git a/requirements.txt b/requirements.txt
index a105403..43aea6c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,3 +7,4 @@ opencv-python-headless==4.10.0.84
numpy==1.26.4
yt-dlp>=2025.10.0
pyacrcloud==1.0.11
+requests==2.32.3
diff --git a/templates/index.html b/templates/index.html
index 5091307..13b98f1 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -925,6 +925,17 @@
actions.push(``);
actions.push(``);
actions.push(``);
+ // Nextcloud upload — različni stanja:
+ const nc = job.nextcloud_status;
+ if (nc === "uploaded") {
+ actions.push(``);
+ } else if (nc === "uploading") {
+ actions.push(``);
+ } else if (nc === "failed") {
+ actions.push(``);
+ } else {
+ actions.push(``);
+ }
}
actions.push(``);
@@ -975,11 +986,29 @@
previewJob(id, title);
} else if (action === "edit") {
openEditModal(id, title);
+ } else if (action === "nextcloud") {
+ uploadToNextcloud(id, title);
} else if (action === "delete") {
deleteJob(id);
}
});
+ async function uploadToNextcloud(id, title) {
+ try {
+ const r = await fetch(`/api/jobs/${id}/upload-nextcloud`, { method: "POST" });
+ if (!r.ok) {
+ const err = await r.json().catch(() => ({}));
+ alert("❌ Napaka: " + (err.detail || r.status));
+ return;
+ }
+ const data = await r.json();
+ // Status will update via SSE / refresh
+ refreshJobs();
+ } catch (e) {
+ alert("❌ Napaka: " + e.message);
+ }
+ }
+
async function deleteJob(id) {
if (!confirm("Izbrišem ta job?")) return;
await fetch(`/api/jobs/${id}`, { method: "DELETE" });