Changes
This commit is contained in:
1 parent
a490152a99
commit
3c29b483c7
5 files changed
+100
-35
No files matched your search
@@ -3,3 +3,4 @@ nohup.out
|
||||
venv
|
||||
downloads
|
||||
cookies.txt
|
||||
.yt-dlp-cache/
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user