From 3c29b483c70d4fb8be8db40cbdd3dd9f7512321f Mon Sep 17 00:00:00 2001 From: AcruxDevelopment Date: Thu, 23 Apr 2026 20:02:37 -0300 Subject: [PATCH] Changes --- .gitignore | 1 + app.py | 96 +++++++++++++++++++++++++++++++++++---------------- start.sh | 4 +-- static/app.js | 9 +++-- yt-dlp.conf | 25 ++++++++++++++ 5 files changed, 100 insertions(+), 35 deletions(-) create mode 100644 yt-dlp.conf diff --git a/.gitignore b/.gitignore index 304c539..fdf5238 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ nohup.out venv downloads cookies.txt +.yt-dlp-cache/ diff --git a/app.py b/app.py index c41151e..4938733 100644 --- a/app.py +++ b/app.py @@ -14,18 +14,42 @@ 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) +# Sanity-check write permissions at startup +_test = os.path.join(DOWNLOAD_DIR, ".write_test") +try: + open(_test, "w").close() + os.remove(_test) +except OSError as e: + raise RuntimeError(f"DOWNLOAD_DIR is not writable: {e}") + jobs = {} AUDIO_FORMATS = ["mp3", "aac", "m4a", "opus", "flac", "wav", "vorbis", "alac"] VIDEO_FORMATS = ["mp4", "mkv", "webm", "mov", "avi", "flv"] +# --------------------------------------------------------------------------- +# Maps each output container to the audio codec FFmpeg should use when +# merging. "copy" means the audio stream is passed through without +# re-encoding (safe when the container supports the source codec). +# Containers like mp4/mov/avi are strict about which audio codecs they +# accept, so we re-encode to a known-compatible codec for those. +# --------------------------------------------------------------------------- +CONTAINER_AUDIO = { + "mp4": "aac", # opus/webm audio is incompatible with mp4 + "mov": "aac", # same restriction as mp4 + "avi": "mp3", # avi supports mp3 broadly + "flv": "aac", # flv works with aac + "mkv": "copy", # mkv supports virtually every codec + "webm": "copy", # webm+opus/vorbis is fine as-is +} + # --------------------------------------------------------------------------- # 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_cache = {} _info_lock = threading.Lock() -INFO_CACHE_TTL = 600 # seconds +INFO_CACHE_TTL = 600 # seconds def _cache_get(url): with _info_lock: @@ -56,23 +80,12 @@ def _random_ua(): # --------------------------------------------------------------------------- # Cookies file — place a cookies.txt (Netscape format, exported from your # browser while logged into YouTube) next to app.py to authenticate requests. -# Authenticated sessions are routed to faster CDN endpoints and bypass the -# throttling YouTube applies to anonymous traffic. # The file is optional; if absent yt-dlp falls back to anonymous mode. # --------------------------------------------------------------------------- COOKIES_FILE = os.path.join(os.path.dirname(__file__), "cookies.txt") # --------------------------------------------------------------------------- # Base yt-dlp options shared by all calls. -# -# cookiefile - browser cookies for authenticated (fast) CDN routing. -# cachedir - persists solved JS sig/nsig to disk so node only re-runs -# when YouTube rotates their player (every few days). -# http_chunk_size - 10 MB chunks; plays nicer with YouTube CDN. -# js_runtimes - use local node v20+ for JS challenge solving. -# -# NOTE: sleep_interval intentionally removed — it was artificially capping -# download speed. Anti-throttle measures live in the frontend instead. # --------------------------------------------------------------------------- def _base_opts(extra=None): opts = { @@ -99,17 +112,17 @@ def sanitize_filename(name): # Background download worker # --------------------------------------------------------------------------- def run_download(job_id, url, fmt, chosen_format): - jobs[job_id]["status"] = "downloading" + jobs[job_id]["status"] = "downloading" jobs[job_id]["progress"] = 0 def progress_hook(d): if d["status"] == "downloading": - total = d.get("total_bytes") or d.get("total_bytes_estimate", 0) + total = d.get("total_bytes") or d.get("total_bytes_estimate", 0) downloaded = d.get("downloaded_bytes", 0) if total: 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]["speed"] = d.get("_speed_str", "") + jobs[job_id]["eta"] = d.get("_eta_str", "") elif d["status"] == "finished": jobs[job_id]["progress"] = 99 @@ -117,33 +130,49 @@ def run_download(job_id, url, fmt, chosen_format): common = _base_opts({"progress_hooks": [progress_hook]}) if fmt == "audio": - codec = chosen_format or "mp3" + # Sanitize codec choice — fall back to mp3 if unrecognised + codec = chosen_format if chosen_format in AUDIO_FORMATS else "mp3" ydl_opts = { **common, "format": "bestaudio/best", - "outtmpl": os.path.join(DOWNLOAD_DIR, f"{job_id}_%(title)s.%(ext)s"), + # Use only the job_id in the filename to avoid OS-level + # permission errors caused by special characters in titles. + "outtmpl": os.path.join(DOWNLOAD_DIR, f"{job_id}.%(ext)s"), + "tmpdir": DOWNLOAD_DIR, "postprocessors": [{ "key": "FFmpegExtractAudio", "preferredcodec": codec, "preferredquality": "192", }], } + else: - video_fmt = chosen_format or "mp4" + # Sanitize container choice — fall back to mp4 if unrecognised + video_fmt = chosen_format if chosen_format in VIDEO_FORMATS else "mp4" + # Look up the audio codec compatible with this container. + # mkv/webm streams are copied as-is; mp4/mov/avi/flv re-encode + # audio so FFmpeg doesn't refuse the merge. + audio_codec = CONTAINER_AUDIO.get(video_fmt, "aac") + ydl_opts = { **common, + # Let yt-dlp freely pick the best video+audio streams without + # restricting by extension — quality is maximised this way. "format": "bestvideo+bestaudio/best", - "outtmpl": os.path.join(DOWNLOAD_DIR, f"{job_id}_%(title)s.%(ext)s"), + "outtmpl": os.path.join(DOWNLOAD_DIR, f"{job_id}.%(ext)s"), + "tmpdir": DOWNLOAD_DIR, "merge_output_format": video_fmt, - "postprocessors": [{ - "key": "FFmpegVideoConvertor", - "preferedformat": video_fmt, - }], + # Pass codec flags directly to the FFmpeg merge step: + # -c:v copy — video is never re-encoded (fast, lossless) + # -c:a ... — audio copied or re-encoded as needed + "postprocessor_args": { + "merger": ["-c:v", "copy", "-c:a", audio_codec] + }, } with yt_dlp.YoutubeDL(ydl_opts) as ydl: - info = ydl.extract_info(url, download=True) - title = info.get("title", "video") + info = ydl.extract_info(url, download=True) + title = sanitize_filename(info.get("title", "video")) files = [f for f in os.listdir(DOWNLOAD_DIR) if f.startswith(job_id)] if files: @@ -213,8 +242,8 @@ def start_download(): 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, @@ -238,13 +267,20 @@ def download_file(job_id): job = jobs.get(job_id) if not job or job.get("status") != "done": return jsonify({"error": "File not ready"}), 404 + filepath = os.path.join(DOWNLOAD_DIR, job["filename"]) if not os.path.exists(filepath): return jsonify({"error": "File missing"}), 404 + + # Serve the file with the original (sanitized) video title as the + # download name, preserving the correct extension. + ext = os.path.splitext(job["filename"])[1] + download_name = f"{job['title']}{ext}" + return send_file( filepath, as_attachment=True, - download_name=job["filename"].split("_", 1)[-1], + download_name=download_name, ) diff --git a/start.sh b/start.sh index bad2121..ff1380d 100755 --- a/start.sh +++ b/start.sh @@ -6,5 +6,5 @@ if [ -f $FILE ]; then fi fuser -k 5000/tcp source venv/bin/activate -nohup python app.py & -echo $! > $FILE +python app.py +echo $$ > $FILE diff --git a/static/app.js b/static/app.js index 1b85051..d198686 100644 --- a/static/app.js +++ b/static/app.js @@ -6,6 +6,9 @@ let currentFetchController = null; let availableFormats = { audio: [], video: [] }; +const TEXT_DOWNLOADING = "Descargando..."; +const TEXT_DOWNLOAD = "Descargar"; +const TEXT_COMPLETED = "Completed"; // --------------------------------------------------------------------------- // Only fire /info when the input looks like a real supported URL. // --------------------------------------------------------------------------- @@ -134,7 +137,7 @@ async function startDownload() { document.getElementById('download-btn-wrap').style.display = 'none'; document.getElementById('progress-box').classList.add('visible'); - document.getElementById('progress-label').textContent = 'Downloading...'; + document.getElementById('progress-label').textContent = TEXT_DOWNLOADING; document.getElementById('progress-fill').style.width = '0%'; document.getElementById('prog-pct').textContent = '0%'; document.getElementById('prog-speed').textContent = ''; @@ -169,7 +172,7 @@ function pollStatus(jobId) { if (data.status === 'done') { clearInterval(pollTimer); - document.getElementById('progress-label').textContent = 'Complete!'; + document.getElementById('progress-label').textContent = TEXT_COMPLETED; document.getElementById('prog-speed').textContent = ''; showStatus('✓ Download ready: ' + (data.title || 'file'), 'success'); document.getElementById('dl-link').href = '/file/' + jobId; @@ -188,7 +191,7 @@ function pollStatus(jobId) { function resetBtn() { const btn = document.getElementById('start-btn'); - btn.disabled = false; btn.innerHTML = 'Download'; + btn.disabled = false; btn.innerHTML = TEXT_DOWNLOAD; } // --------------------------------------------------------------------------- diff --git a/yt-dlp.conf b/yt-dlp.conf new file mode 100644 index 0000000..439518e --- /dev/null +++ b/yt-dlp.conf @@ -0,0 +1,25 @@ +# yt-dlp configuration file +# Place this at ~/.config/yt-dlp/config +# yt-dlp reads this automatically on every invocation — including ones +# spawned by Flask — so no flags need to be passed in code. + +# ------------------------------------------------------------------ +# Cache — persists the YouTube player JS and API responses to disk. +# The player is re-used across all downloads until YouTube rotates it +# (typically every few days), eliminating the repeated +# "Downloading player / Solving JS challenges" steps. +# ------------------------------------------------------------------ +--cache-dir ~/.cache/yt-dlp + +# ------------------------------------------------------------------ +# JS runtime — node for solving YouTube's JS challenges. +# Avoids yt-dlp having to search PATH on every invocation. +# ------------------------------------------------------------------ +--js-runtimes node:/usr/bin/node + +# ------------------------------------------------------------------ +# Network +# ------------------------------------------------------------------ +--socket-timeout 30 +--retries 6 +--fragment-retries 6