From dbb8ab3059a65d86b31bb1df0eb702a8590ed1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastjan=20Arti=C4=8D?= Date: Thu, 30 Apr 2026 14:23:15 +0000 Subject: [PATCH] Nextcloud upload za FOLX SLOVENIJA reels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User wants reels saved directly to Nextcloud /folxspeed/REELS/FOLX SLOVENIJA/ NEW backend endpoint: POST /api/jobs/{id}/upload-nextcloud - WebDAV PUT preko stdlib urllib (no new deps) - Uses NEXTCLOUD_URL/USER/PASS/REELS_PATH env vars - Updates job status: uploading → uploaded / error - Stores nextcloud_url + nextcloud_error in job Frontend already had button (☁ Nextcloud) and handler — just needed backend endpoint. UI states: - ☁ Nextcloud (blue) — not yet uploaded - ☁ ✓ Nextcloud (green) — uploaded successfully - ☁ ✕ Poskusi znova (red) — upload failed (hover for error) Env vars added in Coolify: - NEXTCLOUD_URL=https://nextcloud.folx.tv - NEXTCLOUD_USER=admin - NEXTCLOUD_PASS= - NEXTCLOUD_REELS_PATH=folxspeed/REELS/FOLX SLOVENIJA --- app/main.py | 173 +++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + templates/index.html | 29 ++++++++ 3 files changed, 203 insertions(+) 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" });