| | 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) |