99import json
1010import subprocess
1111import threading
12+ from concurrent .futures import ThreadPoolExecutor
1213from typing import Any
1314from urllib .parse import urlparse
1415
2930app = Flask (__name__ )
3031
3132DOWNLOAD_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" )
3234os .makedirs (DOWNLOAD_DIR , exist_ok = True )
3335
3436MAX_TITLE_LEN = 80
3537JOB_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
3841jobs : dict [str , dict [str , Any ]] = {}
3942jobs_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:
6367def _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
7577def _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+
8291def _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" ])
251325def 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>" )
297475def 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>" )
315492def 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" ])
340514def 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
0 commit comments