This commit is contained in:
alejo committed 2026-04-23 20:02:37 -03:00
1 parent a490152a99
commit 3c29b483c7
5 files changed
+100 -35

No files matched your search

+1
View File
@@ -3,3 +3,4 @@ nohup.out
venv
downloads
cookies.txt
.yt-dlp-cache/
+66 -30
View File
@@ -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,
)
+2 -2
View File
@@ -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
+6 -3
View File
@@ -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;
}
// ---------------------------------------------------------------------------
+25
View File
@@ -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