From 8d9c05b15190fae87c4762d8e3d3010a9074ae8c Mon Sep 17 00:00:00 2001 From: luzadev Date: Sat, 30 May 2026 13:12:31 +0200 Subject: [PATCH] Aggiunta tab Video: YouTube, TikTok, Instagram, Facebook - core/downloader.py: download_video() con format selector qualita (best / 1080p / 720p / 480p), merge MP4, tracking .downloaded_videos - api/bridge.py: start_video_download / stop_video_download / _video_worker. Guard _any_job_running() per evitare concorrenza tra audio e video (condividono lo stesso _stop_event) - webui: nuova nav item, view con hero rosa (gradient pink->black), selettore qualita, hint cookies per Instagram/Facebook privati - CSS: palette pink, .hero-pink, .btn-pink, .progress-fill.pink, componente .hint riusabile - JS: handlers tab Video, canale bridge video:progress/video:done Co-Authored-By: Claude Opus 4.7 --- api/bridge.py | 116 ++++++++++++++++++++++++++++++++++- core/downloader.py | 146 ++++++++++++++++++++++++++++++++++++++++++++ webui/css/style.css | 43 +++++++++++++ webui/index.html | 72 ++++++++++++++++++++++ webui/js/app.js | 81 +++++++++++++++++++++++- 5 files changed, 455 insertions(+), 3 deletions(-) diff --git a/api/bridge.py b/api/bridge.py index 13f1b56..eb069ce 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -13,6 +13,7 @@ from core.config import load_config, save_config, VERSION from core.downloader import ( download_playlist, download_direct_url, + download_video, request_stop as request_download_stop, reset_stop as reset_download_stop, is_stopped as download_is_stopped, @@ -55,6 +56,7 @@ class Api: self.window = None # impostato dopo create_window self._download_thread: Optional[threading.Thread] = None self._upgrade_thread: Optional[threading.Thread] = None + self._video_thread: Optional[threading.Thread] = None # ------------------------------------------------------------------ # Helpers @@ -188,9 +190,15 @@ class Api: # ------------------------------------------------------------------ # DOWNLOAD # ------------------------------------------------------------------ + def _any_job_running(self) -> bool: + return ( + (self._download_thread and self._download_thread.is_alive()) or + (self._video_thread and self._video_thread.is_alive()) + ) + def start_download(self, payload: dict) -> dict: - if self._download_thread and self._download_thread.is_alive(): - return {"ok": False, "error": "Download gia in corso"} + if self._any_job_running(): + return {"ok": False, "error": "Un download gia in corso"} urls = payload.get("urls") or [] output_dir = (payload.get("output_dir") or "").strip() @@ -388,3 +396,107 @@ class Api: upgrade_folder(directory, threshold, cookies_path, recursive, progress_cb) self._emit("upgrade:done", {"ok": True}) + + # ------------------------------------------------------------------ + # VIDEO (YouTube, TikTok, Instagram, Facebook) + # ------------------------------------------------------------------ + def start_video_download(self, payload: dict) -> dict: + if self._any_job_running(): + return {"ok": False, "error": "Un download gia in corso"} + + urls = payload.get("urls") or [] + output_dir = (payload.get("output_dir") or "").strip() + quality = payload.get("quality") or "1080p" + + if not urls: + return {"ok": False, "error": "Nessun URL fornito"} + if not output_dir: + return {"ok": False, "error": "Cartella output non impostata"} + + self._video_thread = threading.Thread( + target=self._video_worker, + args=(list(urls), output_dir, quality), + daemon=True, + ) + self._video_thread.start() + return {"ok": True} + + def stop_video_download(self) -> dict: + request_download_stop() + self._log("video", "[INFO] Interruzione richiesta...") + return {"ok": True} + + def _video_worker(self, urls: list, output_dir: str, quality: str) -> None: + reset_download_stop() + cfg = load_config() + cookies_path = cfg.get("cookies_path", "") + total_urls = len(urls) + multi = total_urls > 1 + view = "video" + + for url_idx, url in enumerate(urls): + if download_is_stopped(): + self._log(view, "[INFO] Download interrotto.") + break + + if multi: + self._log(view, f"\n{'='*50}") + self._log(view, f"[LISTA] URL {url_idx + 1} di {total_urls}") + self._log(view, f"{'='*50}") + + _last = [0.0] + _THROTTLE = 0.10 + url_base = url_idx / total_urls if multi else 0 + url_weight = 1 / total_urls if multi else 1 + + def progress_cb(idx, total, track_name, status, pct, + _base=url_base, _weight=url_weight, _uidx=url_idx): + if status == "downloading": + now = time.monotonic() + if now - _last[0] < _THROTTLE: + return + _last[0] = now + + payload_evt = { + "idx": idx, "total": total, "track": track_name, + "status": status, "pct": pct, + "url_idx": _uidx, "url_total": total_urls, + } + + if status == "skipped": + self._log(view, f"[SKIP] {track_name} (gia scaricato)") + track_progress = (idx + 1) / total if total > 0 else 1 + payload_evt["overall"] = min(_base + track_progress * _weight, 1.0) + elif status == "downloading": + track_progress = (idx / total) + (pct / 100 / total) if total > 0 else 0 + payload_evt["overall"] = min(_base + track_progress * _weight, 1.0) + elif status == "done": + self._log(view, f"[OK] {track_name}") + track_progress = (idx + 1) / total if total > 0 else 1 + payload_evt["overall"] = min(_base + track_progress * _weight, 1.0) + elif status == "stopped": + self._log(view, "[INFO] Download interrotto.") + elif status == "completed": + if not multi: + payload_evt["overall"] = 1.0 + self._log(view, "[INFO] Download completato!") + elif status.startswith("error"): + self._log(view, f"[ERRORE] {track_name}: {status}") + + self._emit("video:progress", payload_evt) + + self._log(view, f"[INFO] Video: {url}") + try: + download_video(url, output_dir, quality, cookies_path, progress_cb) + except Exception as e: + self._log(view, f"[ERRORE] {e}") + continue + + if multi and not download_is_stopped(): + self._log(view, f"\n[INFO] Completate tutte le {total_urls} URL!") + self._emit("video:progress", { + "status": "completed", "overall": 1.0, + "url_total": total_urls, + }) + + self._emit("video:done", {"ok": True}) diff --git a/core/downloader.py b/core/downloader.py index 84f2aa6..e89f0e3 100644 --- a/core/downloader.py +++ b/core/downloader.py @@ -393,3 +393,149 @@ def download_direct_url( if progress_callback and not is_stopped(): progress_callback(total, total, "", "completed", 100) + + +def _video_format(quality: str) -> str: + """Costruisce la format string yt-dlp per la qualita richiesta.""" + if not quality or quality == "best": + return "bv*+ba/b" + h = quality.rstrip("pP") + try: + h_int = int(h) + except ValueError: + h_int = 1080 + return ( + f"bv*[height<=?{h_int}][ext=mp4]+ba[ext=m4a]/" + f"bv*[height<=?{h_int}]+ba/" + f"b[height<=?{h_int}][ext=mp4]/" + f"b[height<=?{h_int}]/b" + ) + + +def download_video( + url: str, + output_dir: str, + quality: str = "1080p", + cookies_path: Optional[str] = None, + progress_callback: Optional[Callable] = None, +) -> None: + """Scarica video (con audio) da YouTube/TikTok/Instagram/Facebook/etc. + + quality: "best" | "1080p" | "720p" | "480p" + Per Instagram/Facebook privati servono cookies validi. + """ + global _current_process + reset_stop() + out_path = Path(output_dir) + out_path.mkdir(parents=True, exist_ok=True) + done_file = out_path / ".downloaded_videos" + done_set = _load_done_set(done_file) + ytdlp = find_ytdlp() + + probe_cmd = [ + ytdlp, "--dump-json", "--flat-playlist", "--no-download", "--no-warnings", url, + ] + if cookies_path and Path(cookies_path).exists(): + probe_cmd.extend(["--cookies", cookies_path]) + + try: + result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60) + if result.returncode != 0: + raise ValueError(result.stderr.strip()) + except Exception as e: + if progress_callback: + progress_callback(0, 0, url, f"error: {e}", 0) + return + + entries = [] + for line in result.stdout.strip().splitlines(): + if line.strip(): + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + + if not entries: + if progress_callback: + progress_callback(0, 0, url, "error: Nessun contenuto trovato", 0) + return + + total = len(entries) + fmt = _video_format(quality) + + for i, entry in enumerate(entries): + if is_stopped(): + if progress_callback: + progress_callback(i, total, "", "stopped", 0) + return + + title = entry.get("title", f"Video {i + 1}") + entry_url = entry.get("url") or entry.get("webpage_url") or entry.get("original_url") or url + if total == 1: + entry_url = url + + track_key = entry.get("id") or title + if track_key in done_set: + if progress_callback: + progress_callback(i, total, title, "skipped", 100) + continue + + if progress_callback: + progress_callback(i, total, title, "downloading", 0) + + cmd = [ + ytdlp, + "-f", fmt, + "--merge-output-format", "mp4", + "--add-metadata", + "--no-check-certificates", + "--newline", + "--output", str(Path(output_dir) / "%(title)s [%(id)s].%(ext)s"), + entry_url, + ] + ffmpeg_dir = find_ffmpeg_dir() + if ffmpeg_dir: + cmd.extend(["--ffmpeg-location", ffmpeg_dir]) + if cookies_path and Path(cookies_path).exists(): + cmd.extend(["--cookies", cookies_path]) + + try: + with _process_lock: + _current_process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + + for line in _current_process.stdout: + if is_stopped(): + _current_process.terminate() + if progress_callback: + progress_callback(i, total, title, "stopped", 0) + return + + pct_match = re.search(r"(\d+(?:\.\d+)?)%", line) + if pct_match and progress_callback: + pct = int(float(pct_match.group(1))) + progress_callback(i, total, title, "downloading", pct) + + return_code = _current_process.wait() + + with _process_lock: + _current_process = None + + if return_code == 0: + _mark_done(done_file, track_key) + done_set.add(track_key) + if progress_callback: + progress_callback(i, total, title, "done", 100) + else: + if progress_callback: + progress_callback(i, total, title, f"error: yt-dlp exit {return_code}", 0) + + except Exception as e: + with _process_lock: + _current_process = None + if progress_callback: + progress_callback(i, total, title, f"error: {e}", 0) + + if progress_callback and not is_stopped(): + progress_callback(total, total, "", "completed", 100) diff --git a/webui/css/style.css b/webui/css/style.css index 5632568..aea449f 100644 --- a/webui/css/style.css +++ b/webui/css/style.css @@ -26,6 +26,10 @@ --purple-hover: #C084FC; --purple-dim: #2D1B4D; + --pink: #EC4899; + --pink-hover: #F472B6; + --pink-dim: #831843; + --red: #E22134; --red-hover: #FF4055; @@ -225,6 +229,10 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; } background: linear-gradient(135deg, #5D2C9C 0%, #2D1B4D 60%, #1A0F2E 100%); } +.hero-pink { + background: linear-gradient(135deg, #EC4899 0%, #831843 60%, #3F0A23 100%); +} + .hero-content { position: relative; z-index: 2; max-width: 70%; } .hero-eyebrow { @@ -237,6 +245,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; } .hero-eyebrow.blue { color: var(--blue); } .hero-eyebrow.purple { color: var(--purple); } +.hero-eyebrow.pink { color: var(--pink-hover); } .hero-title { font-size: 36px; @@ -449,6 +458,11 @@ input[type="number"]::-webkit-inner-spin-button { color: #fff; } +.btn-pink { + background: var(--pink); + color: #fff; +} + .btn-success { background: #00CEC9; color: #000; @@ -512,6 +526,7 @@ input[type="number"]::-webkit-inner-spin-button { .percent.green { color: var(--green); } .percent.blue { color: var(--blue); } +.percent.pink { color: var(--pink-hover); } .progress { width: 100%; @@ -536,6 +551,10 @@ input[type="number"]::-webkit-inner-spin-button { background: linear-gradient(90deg, var(--blue), var(--blue-hover)); } +.progress-fill.pink { + background: linear-gradient(90deg, var(--pink), var(--pink-hover)); +} + /* =============================== LOG BOX =============================== */ @@ -559,6 +578,30 @@ input[type="number"]::-webkit-inner-spin-button { .log .l-warn { color: #FDCB6E; } .log .l-muted { color: var(--text-3); } +/* =============================== + HINT BOX + =============================== */ +.hint { + display: flex; + align-items: center; + gap: 10px; + margin-top: 4px; + padding: 10px 14px; + background: rgba(236, 72, 153, 0.08); + border: 1px solid rgba(236, 72, 153, 0.2); + border-radius: var(--r-sm); + color: var(--text-2); + font-size: 12.5px; +} + +.hint-ico { + color: var(--pink-hover); + font-size: 14px; + font-weight: 800; +} + +.hint strong { color: var(--text); font-weight: 700; } + /* =============================== CHECKBOX =============================== */ diff --git a/webui/index.html b/webui/index.html index 9487148..bdd8c11 100644 --- a/webui/index.html +++ b/webui/index.html @@ -25,6 +25,10 @@ ⬇ Scarica + + + + +
+ + +
+ +
+ ⓘ + Per Instagram/Facebook privati serve un file cookies.txt valido (Impostazioni → Percorsi). +
+ + +
+ + +
+
+ +
+
+ Progresso + 0% +
+
+
+
+
+ + +
+
+
+ +
diff --git a/webui/js/app.js b/webui/js/app.js index 824a656..21f26c0 100644 --- a/webui/js/app.js +++ b/webui/js/app.js @@ -10,9 +10,11 @@ const state = { urlList: [], dlOutputDir: "", upDir: "", + videoOutputDir: "", showSecret: false, downloading: false, upgrading: false, + videoDownloading: false, }; // Wait for pywebview ready @@ -80,12 +82,37 @@ const bridgeHandlers = { $("#upgradeBtn").disabled = false; $("#stopUpgradeBtn").disabled = true; }, + + "video:progress": (p) => { + if (typeof p.overall === "number") { + const pct = Math.round(p.overall * 100); + $("#videoProgressFill").style.width = pct + "%"; + $("#videoPercent").textContent = pct + "%"; + } + if (p.status === "downloading" && typeof p.idx === "number") { + const prefix = p.url_total > 1 ? `[${(p.url_idx ?? 0) + 1}/${p.url_total}] ` : ""; + $("#videoCounter").textContent = `${prefix}Video ${p.idx + 1} di ${p.total}`; + } + if (p.status === "completed" && p.url_total > 1) { + $("#videoCounter").textContent = `Completato: ${p.url_total} URL`; + } + }, + + "video:done": () => { + state.videoDownloading = false; + $("#videoDownloadBtn").disabled = false; + $("#stopVideoBtn").disabled = true; + }, }; // ============================================================ // Logging // ============================================================ -const logEls = { download: () => $("#dlLog"), upgrade: () => $("#upLog") }; +const logEls = { + download: () => $("#dlLog"), + upgrade: () => $("#upLog"), + video: () => $("#videoLog"), +}; function classifyLog(msg) { if (msg.startsWith("[ERRORE]")) return "l-err"; @@ -159,6 +186,13 @@ async function init() { $("#dlPathDisplay").classList.remove("empty"); } + // Video tab — usa output_dir come default + state.videoOutputDir = state.config.output_dir || ""; + if (state.videoOutputDir) { + $("#videoPathDisplay").textContent = state.videoOutputDir; + $("#videoPathDisplay").classList.remove("empty"); + } + // Upgrade tab — default threshold $("#upThreshold").value = state.config.hq_threshold || 310; } @@ -235,6 +269,51 @@ $("#stopDownloadBtn").addEventListener("click", async () => { $("#stopDownloadBtn").disabled = true; }); +// ============================================================ +// VIDEO tab +// ============================================================ +$("#videoBrowseBtn").addEventListener("click", async () => { + const path = await window.pywebview.api.browse_directory(); + if (path) { + state.videoOutputDir = path; + $("#videoPathDisplay").textContent = path; + $("#videoPathDisplay").classList.remove("empty"); + } +}); + +$("#videoDownloadBtn").addEventListener("click", async () => { + const url = $("#videoUrlInput").value.trim(); + if (!url) { + toast("Inserisci un URL", "error"); + return; + } + if (!state.videoOutputDir) { + toast("Seleziona una cartella di destinazione", "error"); + return; + } + + state.videoDownloading = true; + $("#videoDownloadBtn").disabled = true; + $("#stopVideoBtn").disabled = false; + $("#videoProgressFill").style.width = "0%"; + $("#videoPercent").textContent = "0%"; + + const res = await window.pywebview.api.start_video_download({ + urls: [url], + output_dir: state.videoOutputDir, + quality: $("#videoQuality").value, + }); + if (!res.ok) { + toast(res.error || "Errore", "error"); + bridgeHandlers["video:done"](); + } +}); + +$("#stopVideoBtn").addEventListener("click", async () => { + await window.pywebview.api.stop_video_download(); + $("#stopVideoBtn").disabled = true; +}); + // ============================================================ // UPGRADE tab // ============================================================