Changes
This commit is contained in:
1 parent
a490152a99
commit
3c29b483c7
5 files changed
+92
-27
No files matched your search
@@ -3,3 +3,4 @@ nohup.out
|
|||||||
venv
|
venv
|
||||||
downloads
|
downloads
|
||||||
cookies.txt
|
cookies.txt
|
||||||
|
.yt-dlp-cache/
|
||||||
@@ -14,16 +14,40 @@ CACHE_DIR = os.path.join(os.path.dirname(__file__), ".yt-dlp-cache")
|
|||||||
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
|
||||||
os.makedirs(CACHE_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 = {}
|
jobs = {}
|
||||||
|
|
||||||
AUDIO_FORMATS = ["mp3", "aac", "m4a", "opus", "flac", "wav", "vorbis", "alac"]
|
AUDIO_FORMATS = ["mp3", "aac", "m4a", "opus", "flac", "wav", "vorbis", "alac"]
|
||||||
VIDEO_FORMATS = ["mp4", "mkv", "webm", "mov", "avi", "flv"]
|
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
|
# In-memory info cache — avoids hitting YouTube again for the same URL
|
||||||
# within INFO_CACHE_TTL seconds (10 minutes).
|
# within INFO_CACHE_TTL seconds (10 minutes).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
_info_cache = {} # url -> {"data": {...}, "ts": float}
|
_info_cache = {}
|
||||||
_info_lock = threading.Lock()
|
_info_lock = threading.Lock()
|
||||||
INFO_CACHE_TTL = 600 # seconds
|
INFO_CACHE_TTL = 600 # seconds
|
||||||
|
|
||||||
@@ -56,23 +80,12 @@ def _random_ua():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Cookies file — place a cookies.txt (Netscape format, exported from your
|
# Cookies file — place a cookies.txt (Netscape format, exported from your
|
||||||
# browser while logged into YouTube) next to app.py to authenticate requests.
|
# 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.
|
# The file is optional; if absent yt-dlp falls back to anonymous mode.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
COOKIES_FILE = os.path.join(os.path.dirname(__file__), "cookies.txt")
|
COOKIES_FILE = os.path.join(os.path.dirname(__file__), "cookies.txt")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Base yt-dlp options shared by all calls.
|
# 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):
|
def _base_opts(extra=None):
|
||||||
opts = {
|
opts = {
|
||||||
@@ -117,33 +130,49 @@ def run_download(job_id, url, fmt, chosen_format):
|
|||||||
common = _base_opts({"progress_hooks": [progress_hook]})
|
common = _base_opts({"progress_hooks": [progress_hook]})
|
||||||
|
|
||||||
if fmt == "audio":
|
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 = {
|
ydl_opts = {
|
||||||
**common,
|
**common,
|
||||||
"format": "bestaudio/best",
|
"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": [{
|
"postprocessors": [{
|
||||||
"key": "FFmpegExtractAudio",
|
"key": "FFmpegExtractAudio",
|
||||||
"preferredcodec": codec,
|
"preferredcodec": codec,
|
||||||
"preferredquality": "192",
|
"preferredquality": "192",
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
|
|
||||||
else:
|
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 = {
|
ydl_opts = {
|
||||||
**common,
|
**common,
|
||||||
|
# Let yt-dlp freely pick the best video+audio streams without
|
||||||
|
# restricting by extension — quality is maximised this way.
|
||||||
"format": "bestvideo+bestaudio/best",
|
"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,
|
"merge_output_format": video_fmt,
|
||||||
"postprocessors": [{
|
# Pass codec flags directly to the FFmpeg merge step:
|
||||||
"key": "FFmpegVideoConvertor",
|
# -c:v copy — video is never re-encoded (fast, lossless)
|
||||||
"preferedformat": video_fmt,
|
# -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:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=True)
|
info = ydl.extract_info(url, download=True)
|
||||||
title = info.get("title", "video")
|
title = sanitize_filename(info.get("title", "video"))
|
||||||
|
|
||||||
files = [f for f in os.listdir(DOWNLOAD_DIR) if f.startswith(job_id)]
|
files = [f for f in os.listdir(DOWNLOAD_DIR) if f.startswith(job_id)]
|
||||||
if files:
|
if files:
|
||||||
@@ -238,13 +267,20 @@ def download_file(job_id):
|
|||||||
job = jobs.get(job_id)
|
job = jobs.get(job_id)
|
||||||
if not job or job.get("status") != "done":
|
if not job or job.get("status") != "done":
|
||||||
return jsonify({"error": "File not ready"}), 404
|
return jsonify({"error": "File not ready"}), 404
|
||||||
|
|
||||||
filepath = os.path.join(DOWNLOAD_DIR, job["filename"])
|
filepath = os.path.join(DOWNLOAD_DIR, job["filename"])
|
||||||
if not os.path.exists(filepath):
|
if not os.path.exists(filepath):
|
||||||
return jsonify({"error": "File missing"}), 404
|
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(
|
return send_file(
|
||||||
filepath,
|
filepath,
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
download_name=job["filename"].split("_", 1)[-1],
|
download_name=download_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ if [ -f $FILE ]; then
|
|||||||
fi
|
fi
|
||||||
fuser -k 5000/tcp
|
fuser -k 5000/tcp
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
nohup python app.py &
|
python app.py
|
||||||
echo $! > $FILE
|
echo $$ > $FILE
|
||||||
+6
-3
@@ -6,6 +6,9 @@ let currentFetchController = null;
|
|||||||
|
|
||||||
let availableFormats = { audio: [], video: [] };
|
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.
|
// 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('download-btn-wrap').style.display = 'none';
|
||||||
document.getElementById('progress-box').classList.add('visible');
|
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('progress-fill').style.width = '0%';
|
||||||
document.getElementById('prog-pct').textContent = '0%';
|
document.getElementById('prog-pct').textContent = '0%';
|
||||||
document.getElementById('prog-speed').textContent = '';
|
document.getElementById('prog-speed').textContent = '';
|
||||||
@@ -169,7 +172,7 @@ function pollStatus(jobId) {
|
|||||||
|
|
||||||
if (data.status === 'done') {
|
if (data.status === 'done') {
|
||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
document.getElementById('progress-label').textContent = 'Complete!';
|
document.getElementById('progress-label').textContent = TEXT_COMPLETED;
|
||||||
document.getElementById('prog-speed').textContent = '';
|
document.getElementById('prog-speed').textContent = '';
|
||||||
showStatus('✓ Download ready: ' + (data.title || 'file'), 'success');
|
showStatus('✓ Download ready: ' + (data.title || 'file'), 'success');
|
||||||
document.getElementById('dl-link').href = '/file/' + jobId;
|
document.getElementById('dl-link').href = '/file/' + jobId;
|
||||||
@@ -188,7 +191,7 @@ function pollStatus(jobId) {
|
|||||||
|
|
||||||
function resetBtn() {
|
function resetBtn() {
|
||||||
const btn = document.getElementById('start-btn');
|
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