Skip to content

Commit d399344

Browse files
committed
feat: merge PR averygan#25 averygan#28 averygan#29 averygan#20 averygan#14 — cookies, playlist, AAC, GIF, batch download
- averygan#25: cookies.txt support (COOKIES_FILE auto-detected, passed to all yt-dlp calls) - averygan#28: YouTube playlist expansion via /api/playlist endpoint (flat-playlist -J) - averygan#29: Force AAC audio codec (-S acodec:aac) for all video/audio downloads - averygan#20: GIF export via ffmpeg 2-pass palettegen/paletteuse, 15fps, max 480px width - averygan#14: Batch download via /api/batch/download + /api/batch/status, ThreadPoolExecutor(3), parallel Download All
1 parent c5e6d08 commit d399344

4 files changed

Lines changed: 298 additions & 33 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@ assets/*.webm
99
.DS_Store
1010
.env
1111
.vscode/
12+
.env.local
13+
.env.production
14+
.env.development
15+
cookies.txt

app.py

Lines changed: 190 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import json
1010
import subprocess
1111
import threading
12+
from concurrent.futures import ThreadPoolExecutor
1213
from typing import Any
1314
from urllib.parse import urlparse
1415

@@ -29,14 +30,17 @@
2930
app = Flask(__name__)
3031

3132
DOWNLOAD_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "downloads")
33+
COOKIES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cookies.txt")
3234
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
3335

3436
MAX_TITLE_LEN = 80
3537
JOB_TTL_SECONDS = 3600 # auto-purge jobs older than 1 hour
36-
MAX_JOBS = 500 # hard cap to prevent memory exhaustion
38+
MAX_JOBS = 500 # hard cap to prevent memory exhaustion
39+
MAX_BATCH_WORKERS = 3
3740

3841
jobs: dict[str, dict[str, Any]] = {}
3942
jobs_lock = threading.Lock()
43+
batch_executor = ThreadPoolExecutor(max_workers=MAX_BATCH_WORKERS)
4044

4145
# ---------------------------------------------------------------------------
4246
# Helpers
@@ -63,24 +67,29 @@ def _validate_url(url: str) -> str | None:
6367
def _sanitize_title(title: str) -> str:
6468
"""Produce a filesystem-safe title string."""
6569
title = title.strip()
66-
# Remove null bytes and control characters
6770
title = re.sub(r'[\x00-\x1f\x7f]', '', title)
6871
title = _SAFE_FILENAME_RE.sub('', title)
6972
title = _MULTI_SPACE_RE.sub(' ', title).strip()
70-
# Prevent hidden files
7173
title = title.lstrip('.')
7274
return title[:MAX_TITLE_LEN].strip()
7375

7476

7577
def _validate_format_id(format_id: str | None) -> bool:
76-
"""Return True if format_id looks safe (alphanumeric, dashes, plus)."""
78+
"""Return True if format_id looks safe."""
7779
if format_id is None:
7880
return True
7981
return bool(_FORMAT_ID_RE.match(format_id))
8082

8183

84+
def _maybe_add_cookies(cmd: list[str]) -> list[str]:
85+
"""Append --cookies flag if cookies.txt exists."""
86+
if os.path.isfile(COOKIES_FILE):
87+
cmd += ["--cookies", COOKIES_FILE]
88+
return cmd
89+
90+
8291
def _purge_stale_jobs() -> None:
83-
"""Remove completed/errored jobs older than JOB_TTL_SECONDS and their files."""
92+
"""Remove completed/errored jobs older than JOB_TTL_SECONDS."""
8493
now = time.time()
8594
stale = []
8695
with jobs_lock:
@@ -117,13 +126,17 @@ def run_download(job_id: str, url: str, format_choice: str, format_id: str | Non
117126
out_template = os.path.join(DOWNLOAD_DIR, f"{job_id}.%(ext)s")
118127

119128
cmd = ["yt-dlp", "--no-playlist", "--no-warnings", "-o", out_template]
129+
cmd = _maybe_add_cookies(cmd)
120130

121131
if format_choice == "audio":
122-
cmd += ["-x", "--audio-format", "mp3"]
132+
cmd += ["-x", "--audio-format", "mp3", "-S", "acodec:aac"]
133+
elif format_choice == "gif":
134+
# For GIF: download best video first, convert with ffmpeg below
135+
cmd += ["-f", "bestvideo+bestaudio/best", "--merge-output-format", "mp4"]
123136
elif format_id:
124-
cmd += ["-f", f"{format_id}+bestaudio/best", "--merge-output-format", "mp4"]
137+
cmd += ["-f", f"{format_id}+bestaudio/best", "--merge-output-format", "mp4", "-S", "acodec:aac"]
125138
else:
126-
cmd += ["-f", "bestvideo+bestaudio/best", "--merge-output-format", "mp4"]
139+
cmd += ["-f", "bestvideo+bestaudio/best", "--merge-output-format", "mp4", "-S", "acodec:aac"]
127140

128141
cmd.append(url)
129142

@@ -152,6 +165,23 @@ def run_download(job_id: str, url: str, format_choice: str, format_id: str | Non
152165
target = [f for f in files if f.endswith(".mp4")]
153166
chosen = target[0] if target else files[0]
154167

168+
# GIF conversion via ffmpeg 2-pass palettegen
169+
if format_choice == "gif":
170+
gif_out = os.path.splitext(chosen)[0] + ".gif"
171+
ffmpeg_cmd = [
172+
"ffmpeg", "-y", "-i", chosen,
173+
"-vf", "fps=15,scale=w='min(480,iw)':h=-2:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse",
174+
gif_out
175+
]
176+
ff_res = subprocess.run(ffmpeg_cmd, capture_output=True, text=True)
177+
if ff_res.returncode != 0:
178+
with jobs_lock:
179+
job["status"] = "error"
180+
job["error"] = "Failed to create GIF"
181+
return
182+
files.append(gif_out)
183+
chosen = gif_out
184+
155185
# Remove intermediate files
156186
for f in files:
157187
if f != chosen:
@@ -205,6 +235,7 @@ def get_info() -> tuple[Response, int] | Response:
205235
return jsonify({"error": url_err}), 400
206236

207237
cmd = ["yt-dlp", "--no-playlist", "--no-warnings", "-j", url]
238+
cmd = _maybe_add_cookies(cmd)
208239
try:
209240
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
210241
if result.returncode != 0:
@@ -213,7 +244,6 @@ def get_info() -> tuple[Response, int] | Response:
213244

214245
info = json.loads(result.stdout)
215246

216-
# Build quality options — keep best format per resolution
217247
best_by_height: dict[int, dict] = {}
218248
for f in info.get("formats", []):
219249
height = f.get("height")
@@ -247,6 +277,50 @@ def get_info() -> tuple[Response, int] | Response:
247277
return jsonify({"error": "Internal server error"}), 500
248278

249279

280+
@app.route("/api/playlist", methods=["POST"])
281+
def get_playlist_info() -> tuple[Response, int] | Response:
282+
"""Expand a playlist URL into individual video URLs."""
283+
data = request.get_json(silent=True)
284+
if not data:
285+
return jsonify({"error": "Invalid request body"}), 400
286+
287+
url = (data.get("url") or "").strip()
288+
url_err = _validate_url(url)
289+
if url_err:
290+
return jsonify({"error": url_err}), 400
291+
292+
cmd = ["yt-dlp", "--flat-playlist", "-J", url]
293+
cmd = _maybe_add_cookies(cmd)
294+
295+
try:
296+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
297+
if result.returncode != 0:
298+
stderr_last = result.stderr.strip().split("\n")[-1] if result.stderr else "Unknown error"
299+
return jsonify({"error": stderr_last}), 400
300+
301+
info = json.loads(result.stdout)
302+
entries = info.get("entries", [])
303+
urls = []
304+
for entry in entries:
305+
entry_url = entry.get("url")
306+
if not entry_url:
307+
continue
308+
if entry_url.startswith("http://") or entry_url.startswith("https://"):
309+
urls.append(entry_url)
310+
elif "youtube.com" in url or "youtu.be" in url:
311+
urls.append(f"https://www.youtube.com/watch?v={entry_url}")
312+
else:
313+
urls.append(entry_url)
314+
return jsonify({"urls": urls})
315+
except subprocess.TimeoutExpired:
316+
return jsonify({"error": "Timed out fetching playlist info"}), 400
317+
except json.JSONDecodeError:
318+
return jsonify({"error": "Failed to parse playlist info"}), 400
319+
except Exception:
320+
log.exception("Error in /api/playlist")
321+
return jsonify({"error": "Internal server error"}), 500
322+
323+
250324
@app.route("/api/download", methods=["POST"])
251325
def start_download() -> tuple[Response, int] | Response:
252326
_purge_stale_jobs()
@@ -261,14 +335,14 @@ def start_download() -> tuple[Response, int] | Response:
261335
return jsonify({"error": url_err}), 400
262336

263337
format_choice = data.get("format", "video")
264-
if format_choice not in ("video", "audio"):
338+
if format_choice not in ("video", "audio", "gif"):
265339
return jsonify({"error": "Invalid format"}), 400
266340

267341
format_id = data.get("format_id")
268342
if not _validate_format_id(format_id):
269343
return jsonify({"error": "Invalid format_id"}), 400
270344

271-
title = (data.get("title") or "")[:200] # cap title length from client
345+
title = (data.get("title") or "")[:200]
272346

273347
with jobs_lock:
274348
if len(jobs) >= MAX_JOBS:
@@ -293,9 +367,112 @@ def start_download() -> tuple[Response, int] | Response:
293367
return jsonify({"job_id": job_id})
294368

295369

370+
@app.route("/api/batch/download", methods=["POST"])
371+
def batch_download() -> tuple[Response, int] | Response:
372+
"""Start multiple downloads in parallel (max 20 URLs, 3 concurrent workers)."""
373+
_purge_stale_jobs()
374+
375+
data = request.get_json(silent=True)
376+
if not data:
377+
return jsonify({"error": "Invalid request body"}), 400
378+
379+
urls = data.get("urls", [])
380+
if not urls or not isinstance(urls, list):
381+
return jsonify({"error": "No URLs provided"}), 400
382+
if len(urls) > 20:
383+
return jsonify({"error": "Maximum 20 URLs per batch"}), 400
384+
385+
format_choice = data.get("format", "video")
386+
if format_choice not in ("video", "audio", "gif"):
387+
return jsonify({"error": "Invalid format"}), 400
388+
389+
format_id = data.get("format_id")
390+
if not _validate_format_id(format_id):
391+
return jsonify({"error": "Invalid format_id"}), 400
392+
393+
batch_id = uuid.uuid4().hex[:10]
394+
job_ids = []
395+
396+
for raw_url in urls:
397+
url = (raw_url or "").strip()
398+
if not url:
399+
continue
400+
if _validate_url(url):
401+
continue # skip invalid URLs silently
402+
job_id = uuid.uuid4().hex[:10]
403+
with jobs_lock:
404+
jobs[job_id] = {
405+
"status": "downloading",
406+
"url": url,
407+
"batch_id": batch_id,
408+
"title": "",
409+
"created": time.time(),
410+
}
411+
job_ids.append(job_id)
412+
batch_executor.submit(run_download, job_id, url, format_choice, format_id)
413+
414+
with jobs_lock:
415+
jobs[batch_id] = {
416+
"status": "batch",
417+
"job_ids": job_ids,
418+
"total": len(job_ids),
419+
"created": time.time(),
420+
}
421+
422+
return jsonify({
423+
"batch_id": batch_id,
424+
"job_ids": job_ids,
425+
"total": len(job_ids),
426+
})
427+
428+
429+
@app.route("/api/batch/status/<batch_id>")
430+
def batch_status(batch_id: str) -> tuple[Response, int] | Response:
431+
"""Get aggregated status of all jobs in a batch."""
432+
if not re.match(r'^[0-9a-f]{10}$', batch_id):
433+
return jsonify({"error": "Invalid batch ID"}), 400
434+
435+
with jobs_lock:
436+
batch = jobs.get(batch_id)
437+
if not batch or batch.get("status") != "batch":
438+
return jsonify({"error": "Batch not found"}), 404
439+
440+
job_ids = batch.get("job_ids", [])
441+
results = []
442+
done_count = 0
443+
error_count = 0
444+
445+
for jid in job_ids:
446+
with jobs_lock:
447+
job = jobs.get(jid)
448+
if not job:
449+
results.append({"job_id": jid, "status": "unknown"})
450+
continue
451+
results.append({
452+
"job_id": jid,
453+
"status": job["status"],
454+
"error": job.get("error"),
455+
"filename": job.get("filename"),
456+
})
457+
if job["status"] == "done":
458+
done_count += 1
459+
elif job["status"] == "error":
460+
error_count += 1
461+
462+
all_done = (done_count + error_count) >= len(job_ids)
463+
return jsonify({
464+
"batch_id": batch_id,
465+
"total": len(job_ids),
466+
"done": done_count,
467+
"errors": error_count,
468+
"pending": len(job_ids) - done_count - error_count,
469+
"all_done": all_done,
470+
"jobs": results,
471+
})
472+
473+
296474
@app.route("/api/status/<job_id>")
297475
def check_status(job_id: str) -> tuple[Response, int] | Response:
298-
# Validate job_id format (hex, 10 chars)
299476
if not re.match(r'^[0-9a-f]{10}$', job_id):
300477
return jsonify({"error": "Invalid job ID"}), 400
301478

@@ -313,7 +490,6 @@ def check_status(job_id: str) -> tuple[Response, int] | Response:
313490

314491
@app.route("/api/file/<job_id>")
315492
def download_file(job_id: str) -> tuple[Response, int] | Response:
316-
# Validate job_id format
317493
if not re.match(r'^[0-9a-f]{10}$', job_id):
318494
return jsonify({"error": "Invalid job ID"}), 400
319495

@@ -323,8 +499,6 @@ def download_file(job_id: str) -> tuple[Response, int] | Response:
323499
return jsonify({"error": "File not ready"}), 404
324500

325501
filepath = job.get("file", "")
326-
327-
# Path traversal protection: ensure file is inside DOWNLOAD_DIR
328502
real_path = os.path.realpath(filepath)
329503
if not real_path.startswith(os.path.realpath(DOWNLOAD_DIR)):
330504
log.warning("Path traversal attempt blocked: %s", filepath)
@@ -338,7 +512,7 @@ def download_file(job_id: str) -> tuple[Response, int] | Response:
338512

339513
@app.route("/api/cleanup/<job_id>", methods=["POST"])
340514
def cleanup_job(job_id: str) -> tuple[Response, int] | Response:
341-
"""Allow clients to signal they've downloaded the file so we can clean up."""
515+
"""Client signals file was saved — clean up server-side."""
342516
if not re.match(r'^[0-9a-f]{10}$', job_id):
343517
return jsonify({"error": "Invalid job ID"}), 400
344518

docker-compose.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
services:
2+
reclip:
3+
build: .
4+
ports:
5+
- "8899:8899"
6+
volumes:
7+
- ./cookies.txt:/app/cookies.txt
8+
restart: unless-stopped

0 commit comments

Comments
 (0)