| import os |
| import json |
| import time |
| import logging |
| import tempfile |
| import yt_dlp |
| from flask import Flask, Response, render_template, request |
| from werkzeug.utils import secure_filename |
| from threading import Thread, Lock |
| import uuid |
|
|
| app = Flask(__name__) |
|
|
| |
|
|
| def get_temp_download_path(): |
| render_disk_path = os.environ.get('RENDER_DISK_PATH') |
| if render_disk_path: |
| base_path = render_disk_path |
| else: |
| base_path = tempfile.gettempdir() |
| |
| temp_folder = os.path.join(base_path, 'yt_temp_downloads') |
| return temp_folder |
|
|
| DOWNLOAD_FOLDER = get_temp_download_path() |
| COOKIE_FILE = "cookies.txt" |
|
|
| logging.basicConfig(level=logging.INFO) |
| os.makedirs(DOWNLOAD_FOLDER, exist_ok=True) |
|
|
| |
| progress_data = {} |
| progress_lock = Lock() |
|
|
| def update_progress(task_id, status, progress=0, **kwargs): |
| """Update progress data thread-safely""" |
| with progress_lock: |
| progress_data[task_id] = { |
| 'status': status, |
| 'progress': float(progress), |
| 'timestamp': time.time(), |
| **kwargs |
| } |
| app.logger.info(f"PROGRESS: {task_id} -> {status} ({progress}%)") |
|
|
| |
| current_download_task = None |
|
|
| def create_progress_hook(task_id): |
| """Create a progress hook that captures the task_id in closure""" |
| def progress_hook(d): |
| try: |
| if d['status'] == 'downloading': |
| total_bytes = d.get('total_bytes') or d.get('total_bytes_est', 0) |
| downloaded_bytes = d.get('downloaded_bytes', 0) |
| speed = d.get('speed', 0) |
| eta = d.get('eta', 0) |
| |
| if total_bytes > 0: |
| percent = (downloaded_bytes / total_bytes) * 100 |
| |
| percent = min(99.9, percent) |
| else: |
| |
| |
| percent = min(50, downloaded_bytes / (1024 * 1024)) |
| |
| speed_mbps = (speed / (1024 * 1024)) if speed else 0 |
| |
| update_progress( |
| task_id, |
| 'downloading', |
| percent, |
| eta=int(eta) if eta else 0, |
| speed=f"{speed_mbps:.2f} MB/s", |
| downloaded_mb=downloaded_bytes / (1024 * 1024), |
| total_mb=total_bytes / (1024 * 1024) if total_bytes else 0 |
| ) |
| |
| elif d['status'] == 'finished': |
| update_progress(task_id, 'processing', 100) |
| |
| except Exception as e: |
| app.logger.error(f"Progress hook error for {task_id}: {e}") |
| |
| return progress_hook |
|
|
| def download_worker(url, format_choice, task_id): |
| """Download worker function""" |
| global current_download_task |
| current_download_task = task_id |
| |
| try: |
| |
| update_progress(task_id, 'initializing', 5) |
| time.sleep(0.5) |
| |
| |
| update_progress(task_id, 'fetching_info', 10) |
| info_opts = { |
| 'quiet': True, |
| 'no_warnings': True, |
| 'cookiefile': COOKIE_FILE if os.path.exists(COOKIE_FILE) else None |
| } |
| |
| with yt_dlp.YoutubeDL(info_opts) as ydl: |
| info = ydl.extract_info(url, download=False) |
| clean_title = secure_filename(info.get('title', 'video')) |
| app.logger.info(f"Video title: {clean_title}") |
| |
| |
| update_progress(task_id, 'preparing', 15) |
| time.sleep(0.5) |
| |
| |
| timestamp = int(time.time()) |
| audio_formats = {"mp3", "m4a", "webm", "aac", "flac", "opus", "ogg", "wav"} |
| |
| if format_choice in audio_formats: |
| unique_name = f"{clean_title}_{timestamp}" |
| final_filename = f"{unique_name}.{format_choice}" |
| ydl_opts = { |
| 'format': 'bestaudio/best', |
| 'postprocessors': [{ |
| 'key': 'FFmpegExtractAudio', |
| 'preferredcodec': format_choice, |
| 'preferredquality': '192', |
| }] |
| } |
| elif format_choice in ["1080", "720", "480", "360", "1440"]: |
| res = int(format_choice) |
| unique_name = f"{clean_title}_{res}p_{timestamp}" |
| final_filename = f"{unique_name}.mp4" |
| ydl_opts = { |
| 'format': f'bestvideo[height<={res}]+bestaudio/best[height<={res}]', |
| 'merge_output_format': 'mp4' |
| } |
| else: |
| raise ValueError(f"Invalid format: {format_choice}") |
| |
| |
| progress_hook = create_progress_hook(task_id) |
| |
| |
| ydl_opts.update({ |
| 'outtmpl': os.path.join(DOWNLOAD_FOLDER, f"{unique_name}.%(ext)s"), |
| 'progress_hooks': [progress_hook], |
| 'quiet': True, |
| 'no_warnings': True, |
| 'cookiefile': COOKIE_FILE if os.path.exists(COOKIE_FILE) else None |
| }) |
| |
| expected_path = os.path.join(DOWNLOAD_FOLDER, final_filename) |
| |
| |
| update_progress(task_id, 'starting_download', 20) |
| time.sleep(0.5) |
| |
| app.logger.info(f"Starting yt-dlp download for {task_id}") |
| |
| with yt_dlp.YoutubeDL(ydl_opts) as ydl: |
| ydl.download([url]) |
| |
| app.logger.info(f"yt-dlp download completed for {task_id}") |
| |
| |
| actual_filename = final_filename |
| if not os.path.exists(expected_path): |
| |
| base_name = unique_name |
| found_files = [] |
| |
| for filename in os.listdir(DOWNLOAD_FOLDER): |
| if filename.startswith(base_name) and not filename.endswith('.part'): |
| found_files.append(filename) |
| |
| if found_files: |
| |
| found_files.sort(key=lambda x: os.path.getctime(os.path.join(DOWNLOAD_FOLDER, x)), reverse=True) |
| actual_filename = found_files[0] |
| expected_path = os.path.join(DOWNLOAD_FOLDER, actual_filename) |
| app.logger.info(f"Found downloaded file: {actual_filename}") |
| |
| |
| if not os.path.exists(expected_path): |
| raise FileNotFoundError(f"Download completed but file not found: {actual_filename}") |
| |
| file_size = os.path.getsize(expected_path) |
| if file_size == 0: |
| raise ValueError("Downloaded file is empty") |
| |
| app.logger.info(f"File verified: {actual_filename} ({file_size} bytes)") |
| |
| |
| update_progress(task_id, 'complete', 100, filename=actual_filename) |
| |
| except Exception as e: |
| error_msg = str(e) |
| app.logger.error(f"Download error for {task_id}: {error_msg}") |
| update_progress(task_id, 'error', 0, message=error_msg) |
| |
| |
| try: |
| if 'expected_path' in locals() and os.path.exists(expected_path): |
| os.remove(expected_path) |
| app.logger.info(f"Cleaned up failed download: {expected_path}") |
| except Exception as cleanup_error: |
| app.logger.error(f"Cleanup error: {cleanup_error}") |
| |
| finally: |
| |
| if current_download_task == task_id: |
| current_download_task = None |
|
|
| @app.route('/') |
| def index(): |
| return render_template('index.html') |
|
|
| @app.route('/stream-download', methods=['GET']) |
| def stream_download(): |
| url = request.args.get('url') |
| format_choice = request.args.get('format') |
| |
| if not url or not format_choice: |
| return Response( |
| json.dumps({"error": "Missing parameters"}), |
| status=400, |
| mimetype='application/json' |
| ) |
| |
| task_id = str(uuid.uuid4()) |
| app.logger.info(f"New download request: {task_id} - {url} - {format_choice}") |
| |
| |
| update_progress(task_id, 'waiting', 0) |
| |
| |
| thread = Thread(target=download_worker, args=(url, format_choice, task_id)) |
| thread.daemon = True |
| thread.start() |
| |
| def generate(): |
| try: |
| start_time = time.time() |
| timeout = 1800 |
| |
| while True: |
| |
| if time.time() - start_time > timeout: |
| update_progress(task_id, 'error', 0, message='Download timeout') |
| break |
| |
| |
| with progress_lock: |
| data = progress_data.get(task_id, { |
| 'status': 'waiting', |
| 'progress': 0, |
| 'timestamp': time.time() |
| }) |
| |
| |
| json_data = json.dumps(data) |
| yield f"data: {json_data}\n\n" |
| |
| |
| if data.get('status') in ['complete', 'error']: |
| break |
| |
| time.sleep(0.5) |
| |
| except GeneratorExit: |
| app.logger.info(f"Client disconnected: {task_id}") |
| except Exception as e: |
| app.logger.error(f"Stream error for {task_id}: {e}") |
| finally: |
| |
| def cleanup(): |
| time.sleep(30) |
| with progress_lock: |
| if task_id in progress_data: |
| del progress_data[task_id] |
| app.logger.info(f"Cleaned up progress data for {task_id}") |
| |
| Thread(target=cleanup, daemon=True).start() |
| |
| response = Response(generate(), mimetype='text/event-stream') |
| response.headers.update({ |
| 'Cache-Control': 'no-cache', |
| 'Connection': 'keep-alive', |
| 'Access-Control-Allow-Origin': '*', |
| 'X-Accel-Buffering': 'no' |
| }) |
| |
| return response |
|
|
| @app.route('/download-file/<filename>') |
| def download_file(filename): |
| safe_folder = os.path.abspath(DOWNLOAD_FOLDER) |
| filepath = os.path.join(safe_folder, filename) |
| |
| |
| if not os.path.abspath(filepath).startswith(safe_folder): |
| return "Forbidden", 403 |
| |
| if not os.path.exists(filepath): |
| return "File not found", 404 |
| |
| def generate_and_cleanup(): |
| try: |
| app.logger.info(f"Serving file: {filename}") |
| with open(filepath, 'rb') as f: |
| while True: |
| chunk = f.read(8192) |
| if not chunk: |
| break |
| yield chunk |
| finally: |
| |
| def remove_file(): |
| time.sleep(2) |
| try: |
| if os.path.exists(filepath): |
| os.remove(filepath) |
| app.logger.info(f"Cleaned up file: {filename}") |
| except Exception as e: |
| app.logger.error(f"Error removing file {filename}: {e}") |
| |
| Thread(target=remove_file, daemon=True).start() |
| |
| return Response( |
| generate_and_cleanup(), |
| headers={ |
| 'Content-Disposition': f'attachment; filename="{filename}"', |
| 'Content-Type': 'application/octet-stream' |
| } |
| ) |
|
|
| @app.route('/health') |
| def health_check(): |
| return { |
| "status": "healthy", |
| "active_downloads": len(progress_data), |
| "current_task": current_download_task |
| } |
|
|
| if __name__ == "__main__": |
| app.run(debug=True, threaded=True, host='0.0.0.0', port=5000) |