From 76e758339fe8eac0ed56816617868ebcc927f1f1 Mon Sep 17 00:00:00 2001 From: AcruxDevelopment Date: Sat, 21 Mar 2026 01:51:40 -0300 Subject: [PATCH] v2 --- app.py | 196 ++++++++++++++++++-------- templates/index.html | 324 ++++++++++++++++++++++--------------------- 2 files changed, 300 insertions(+), 220 deletions(-) diff --git a/app.py b/app.py index 8d7b1ee..7ad9fe2 100644 --- a/app.py +++ b/app.py @@ -1,23 +1,95 @@ import os import re -import json +import time import threading import uuid +import random from flask import Flask, request, jsonify, send_file, render_template import yt_dlp app = Flask(__name__) DOWNLOAD_DIR = os.path.join(os.path.dirname(__file__), "downloads") +CACHE_DIR = os.path.join(os.path.dirname(__file__), ".yt-dlp-cache") os.makedirs(DOWNLOAD_DIR, exist_ok=True) +os.makedirs(CACHE_DIR, exist_ok=True) -# Track job progress jobs = {} +AUDIO_FORMATS = ["mp3", "aac", "m4a", "opus", "flac", "wav", "vorbis", "alac"] +VIDEO_FORMATS = ["mp4", "mkv", "webm", "mov", "avi", "flv"] + +# --------------------------------------------------------------------------- +# In-memory info cache — avoids hitting YouTube again for the same URL +# within INFO_CACHE_TTL seconds (10 minutes). +# --------------------------------------------------------------------------- +_info_cache = {} # url -> {"data": {...}, "ts": float} +_info_lock = threading.Lock() +INFO_CACHE_TTL = 600 # seconds + +def _cache_get(url): + with _info_lock: + entry = _info_cache.get(url) + if entry and time.time() - entry["ts"] < INFO_CACHE_TTL: + return entry["data"] + return None + +def _cache_set(url, data): + with _info_lock: + _info_cache[url] = {"data": data, "ts": time.time()} + +# --------------------------------------------------------------------------- +# Rotate through realistic browser User-Agents so repeated requests don't +# look like a single bot fingerprint to YouTube. +# --------------------------------------------------------------------------- +_USER_AGENTS = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", +] + +def _random_ua(): + return random.choice(_USER_AGENTS) + +# --------------------------------------------------------------------------- +# Base yt-dlp options shared by all calls. +# +# • cachedir – persists solved JS sig/nsig to disk; node only re-runs +# when YouTube rotates their player (every few days). +# • sleep_interval / +# max_sleep_interval– small random sleep between fragment requests; +# makes traffic pattern look more human. +# • http_chunk_size – stream in 10 MB chunks rather than one giant request; +# reduces the chance of a mid-transfer 429. +# • js_runtimes – use local node v20+ for JS challenge solving. +# --------------------------------------------------------------------------- +def _base_opts(extra=None): + opts = { + "cachedir": CACHE_DIR, + "js_runtimes": {"node": {"executable": "/usr/bin/node"}}, + "http_headers": {"User-Agent": _random_ua()}, + "sleep_interval": 1, + "max_sleep_interval": 3, + "http_chunk_size": 10 * 1024 * 1024, # 10 MB + "socket_timeout": 30, + "retries": 6, + "fragment_retries": 6, + } + if extra: + opts.update(extra) + return opts + + def sanitize_filename(name): return re.sub(r'[\\/*?:"<>|]', "_", name) -def run_download(job_id, url, fmt, quality): + +# --------------------------------------------------------------------------- +# Background download worker +# --------------------------------------------------------------------------- +def run_download(job_id, url, fmt, chosen_format): jobs[job_id]["status"] = "downloading" jobs[job_id]["progress"] = 0 @@ -26,125 +98,121 @@ def run_download(job_id, url, fmt, quality): total = d.get("total_bytes") or d.get("total_bytes_estimate", 0) downloaded = d.get("downloaded_bytes", 0) if total: - pct = int(downloaded / total * 100) - jobs[job_id]["progress"] = pct + jobs[job_id]["progress"] = int(downloaded / total * 100) jobs[job_id]["speed"] = d.get("_speed_str", "") - jobs[job_id]["eta"] = d.get("_eta_str", "") + jobs[job_id]["eta"] = d.get("_eta_str", "") elif d["status"] == "finished": jobs[job_id]["progress"] = 99 try: - import shutil - - common = { - "progress_hooks": [progress_hook], - "quiet": True, - "extractor_args": {"youtube": { - "js_runtimes": [f"node:/usr/bin/node"] - }}, - } + common = _base_opts({"progress_hooks": [progress_hook]}) if fmt == "audio": + codec = chosen_format or "mp3" ydl_opts = { **common, "format": "bestaudio/best", "outtmpl": os.path.join(DOWNLOAD_DIR, f"{job_id}_%(title)s.%(ext)s"), "postprocessors": [{ "key": "FFmpegExtractAudio", - "preferredcodec": quality or "mp3", + "preferredcodec": codec, "preferredquality": "192", }], } else: - res = quality or "best" - if res == "best": - fmt_str = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" - else: - h = res.replace("p", "") - fmt_str = f"bestvideo[height<={h}][ext=mp4]+bestaudio[ext=m4a]/best[height<={h}][ext=mp4]/best" - + video_fmt = chosen_format or "mp4" ydl_opts = { **common, - "format": fmt_str, + "format": "bestvideo+bestaudio/best", "outtmpl": os.path.join(DOWNLOAD_DIR, f"{job_id}_%(title)s.%(ext)s"), - "merge_output_format": "mp4", + "merge_output_format": video_fmt, + "postprocessors": [{ + "key": "FFmpegVideoConvertor", + "preferedformat": video_fmt, + }], } with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(url, download=True) title = info.get("title", "video") - # Find the downloaded file files = [f for f in os.listdir(DOWNLOAD_DIR) if f.startswith(job_id)] if files: - jobs[job_id]["status"] = "done" + jobs[job_id]["status"] = "done" jobs[job_id]["progress"] = 100 jobs[job_id]["filename"] = files[0] - jobs[job_id]["title"] = title + jobs[job_id]["title"] = title else: jobs[job_id]["status"] = "error" - jobs[job_id]["error"] = "File not found after download." + jobs[job_id]["error"] = "File not found after download." except Exception as e: jobs[job_id]["status"] = "error" - jobs[job_id]["error"] = str(e) + jobs[job_id]["error"] = str(e) +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- @app.route("/") def index(): return render_template("index.html") +@app.route("/formats", methods=["GET"]) +def get_formats(): + return jsonify({"audio": AUDIO_FORMATS, "video": VIDEO_FORMATS}) + + @app.route("/info", methods=["POST"]) def get_info(): data = request.get_json() - url = data.get("url", "").strip() + url = data.get("url", "").strip() if not url: return jsonify({"error": "No URL provided"}), 400 + + # Serve from cache if still fresh — no YouTube request at all + cached = _cache_get(url) + if cached: + return jsonify(cached) + try: - import shutil - node_bin = shutil.which("node") or shutil.which("nodejs") or "/usr/bin/node" - ydl_opts = { - "quiet": True, - "skip_download": True, - "extractor_args": {"youtube": { - "js_runtimes": [f"node:{node_bin}"] - }}, - } + ydl_opts = _base_opts({"skip_download": True}) with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(url, download=False) - formats = info.get("formats", []) - heights = sorted(set( - f.get("height") for f in formats - if f.get("height") and f.get("vcodec") != "none" - ), reverse=True) - return jsonify({ - "title": info.get("title"), + + result = { + "title": info.get("title"), "thumbnail": info.get("thumbnail"), - "duration": info.get("duration"), - "uploader": info.get("uploader"), - "resolutions": [f"{h}p" for h in heights[:6]], - }) + "duration": info.get("duration"), + "uploader": info.get("uploader"), + } + _cache_set(url, result) + return jsonify(result) + except Exception as e: return jsonify({"error": str(e)}), 400 @app.route("/download", methods=["POST"]) def start_download(): - data = request.get_json() - url = data.get("url", "").strip() - fmt = data.get("format", "video") - quality = data.get("quality", "best") + data = request.get_json() + url = data.get("url", "").strip() + fmt = data.get("format", "video") + chosen_format = data.get("chosen_format", None) if not url: return jsonify({"error": "No URL provided"}), 400 - job_id = str(uuid.uuid4())[:8] - jobs[job_id] = {"status": "queued", "progress": 0} + job_id = str(uuid.uuid4())[:8] + jobs[job_id] = {"status": "queued", "progress": 0} - t = threading.Thread(target=run_download, args=(job_id, url, fmt, quality), daemon=True) + t = threading.Thread( + target=run_download, + args=(job_id, url, fmt, chosen_format), + daemon=True, + ) t.start() - return jsonify({"job_id": job_id}) @@ -164,8 +232,14 @@ def download_file(job_id): filepath = os.path.join(DOWNLOAD_DIR, job["filename"]) if not os.path.exists(filepath): return jsonify({"error": "File missing"}), 404 - return send_file(filepath, as_attachment=True, download_name=job["filename"].split("_", 1)[-1]) + return send_file( + filepath, + as_attachment=True, + download_name=job["filename"].split("_", 1)[-1], + ) if __name__ == "__main__": - app.run(host="0.0.0.0", port=5000, debug=True) + # use_reloader=False prevents the Werkzeug reloader from forking the + # process and accidentally firing double requests during development. + app.run(host="0.0.0.0", port=5000, debug=True, use_reloader=False) diff --git a/templates/index.html b/templates/index.html index 8057b9e..cad296e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -84,6 +84,7 @@ display: flex; gap: 10px; margin-bottom: 28px; + position: relative; } input[type="text"] { @@ -94,17 +95,29 @@ color: var(--text); font-family: 'Space Mono', monospace; font-size: 0.82rem; - padding: 12px 14px; + padding: 12px 42px 12px 14px; outline: none; transition: border-color 0.2s; } - input[type="text"]:focus { - border-color: var(--accent); - } - + input[type="text"]:focus { border-color: var(--accent); } input[type="text"]::placeholder { color: var(--muted); } + .url-spinner { + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + width: 14px; + height: 14px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.6s linear infinite; + display: none; + } + .url-spinner.active { display: block; } + .btn { font-family: 'Syne', sans-serif; font-weight: 700; @@ -118,29 +131,11 @@ white-space: nowrap; } - .btn-ghost { - background: transparent; - border: 1px solid var(--border); - color: var(--muted); - } - - .btn-ghost:hover { border-color: var(--accent); color: var(--accent); } - - .btn-accent { - background: var(--accent); - color: #000; - } - + .btn-accent { background: var(--accent); color: #000; } .btn-accent:hover { background: #d4eb3a; transform: translateY(-1px); } .btn-accent:active { transform: translateY(0); } + .btn-accent:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } - .btn-accent:disabled { - opacity: 0.4; - cursor: not-allowed; - transform: none; - } - - /* Video info preview */ #info-box { display: none; background: var(--bg); @@ -151,7 +146,6 @@ gap: 14px; animation: fadeUp 0.3s ease both; } - #info-box.visible { display: flex; } .thumb-wrap { @@ -162,12 +156,7 @@ overflow: hidden; background: var(--border); } - - .thumb-wrap img { - width: 100%; - height: 100%; - object-fit: cover; - } + .thumb-wrap img { width: 100%; height: 100%; object-fit: cover; } .info-meta { flex: 1; min-width: 0; } @@ -187,7 +176,6 @@ color: var(--muted); } - /* Format toggle */ .format-toggle { display: flex; background: var(--bg); @@ -214,18 +202,12 @@ justify-content: center; gap: 7px; } - - .fmt-btn.active { - background: var(--accent); - color: #000; - } - + .fmt-btn.active { background: var(--accent); color: #000; } .fmt-btn:not(.active):hover { color: var(--text); } - /* Quality selector */ - #quality-row { margin-bottom: 28px; } + #format-row { margin-bottom: 28px; } - .quality-chips { + .format-chips { display: flex; flex-wrap: wrap; gap: 8px; @@ -242,22 +224,13 @@ color: var(--muted); transition: all 0.15s; background: transparent; + text-transform: uppercase; + letter-spacing: 0.05em; } - - .chip.selected { - border-color: var(--accent); - color: var(--accent); - background: rgba(232, 255, 71, 0.07); - } - + .chip.selected { border-color: var(--accent); color: var(--accent); background: rgba(232,255,71,0.07); } .chip:hover:not(.selected) { border-color: #444; color: var(--text); } - /* Progress */ - #progress-box { - display: none; - margin-bottom: 24px; - } - + #progress-box { display: none; margin-bottom: 24px; } #progress-box.visible { display: block; } .progress-bar-bg { @@ -284,7 +257,6 @@ justify-content: space-between; } - /* Status messages */ #status-msg { font-family: 'Space Mono', monospace; font-size: 0.72rem; @@ -293,30 +265,22 @@ margin-bottom: 20px; display: none; } - #status-msg.error { - background: rgba(255, 80, 80, 0.1); - border: 1px solid rgba(255, 80, 80, 0.3); + background: rgba(255,80,80,0.1); + border: 1px solid rgba(255,80,80,0.3); color: #ff8080; display: block; } - #status-msg.success { - background: rgba(232, 255, 71, 0.08); - border: 1px solid rgba(232, 255, 71, 0.3); + background: rgba(232,255,71,0.08); + border: 1px solid rgba(232,255,71,0.3); color: var(--accent); display: block; } - .action-row { - display: flex; - gap: 10px; - justify-content: flex-end; - } - + .action-row { display: flex; gap: 10px; justify-content: flex-end; } #download-btn-wrap { display: none; } - /* Spinner */ .spin { display: inline-block; width: 12px; height: 12px; @@ -327,16 +291,9 @@ vertical-align: middle; } - @keyframes spin { to { transform: rotate(360deg); } } - - @keyframes fadeDown { - from { opacity: 0; transform: translateY(-20px); } - to { opacity: 1; transform: translateY(0); } - } - @keyframes fadeUp { - from { opacity: 0; transform: translateY(16px); } - to { opacity: 1; transform: translateY(0); } - } + @keyframes spin { to { transform: rotate(360deg); } } + @keyframes fadeDown { from { opacity:0; transform:translateY(-20px); } to { opacity:1; transform:translateY(0); } } + @keyframes fadeUp { from { opacity:0; transform:translateY(16px); } to { opacity:1; transform:translateY(0); } } footer { margin-top: 48px; @@ -358,14 +315,12 @@
-
- - + +
-
thumb
@@ -374,35 +329,25 @@
-
- - +
- -
- -
- -
- - - - - -
+
+ +
-
@@ -414,7 +359,6 @@
-
⬇ Save File @@ -427,91 +371,148 @@
yt-dlp + flask · for personal use only · respect copyright