Spaces:
Sleeping
Sleeping
| """ | |
| Video Bulk Downloader - Single File Self-Hosted Solution | |
| Run: pip install flask requests && python app.py | |
| """ | |
| import os | |
| import re | |
| import io | |
| import zipfile | |
| import requests | |
| from flask import Flask, render_template_string, request, jsonify, send_file | |
| from concurrent.futures import ThreadPoolExecutor | |
| import threading | |
| import time | |
| import uuid | |
| app = Flask(__name__) | |
| # Track download progress | |
| jobs = {} | |
| jobs_lock = threading.Lock() | |
| HTML_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Video Downloader</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); | |
| min-height: 100vh; | |
| color: #fff; | |
| padding: 40px 20px; | |
| } | |
| .container { max-width: 800px; margin: 0 auto; } | |
| h1 { | |
| text-align: center; | |
| margin-bottom: 10px; | |
| font-size: 2.5rem; | |
| background: linear-gradient(90deg, #ff6b6b, #feca57); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| background-clip: text; | |
| } | |
| .subtitle { text-align: center; color: #888; margin-bottom: 30px; } | |
| .card { | |
| background: rgba(255,255,255,0.05); | |
| border-radius: 16px; | |
| padding: 30px; | |
| backdrop-filter: blur(10px); | |
| border: 1px solid rgba(255,255,255,0.1); | |
| } | |
| textarea { | |
| width: 100%; | |
| height: 200px; | |
| background: rgba(0,0,0,0.3); | |
| border: 2px solid rgba(255,255,255,0.1); | |
| border-radius: 12px; | |
| color: #fff; | |
| padding: 15px; | |
| font-size: 14px; | |
| resize: vertical; | |
| transition: border-color 0.3s; | |
| } | |
| textarea:focus { outline: none; border-color: #ff6b6b; } | |
| textarea::placeholder { color: #666; } | |
| .btn { | |
| width: 100%; | |
| padding: 15px 30px; | |
| margin-top: 20px; | |
| border: none; | |
| border-radius: 12px; | |
| font-size: 16px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| background: linear-gradient(90deg, #ff6b6b, #feca57); | |
| color: #1a1a2e; | |
| } | |
| .btn:hover { transform: translateY(-2px); box-shadow: 0 10px 30px rgba(255,107,107,0.3); } | |
| .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } | |
| #status { | |
| margin-top: 25px; | |
| padding: 20px; | |
| background: rgba(0,0,0,0.2); | |
| border-radius: 12px; | |
| display: none; | |
| } | |
| .status-item { | |
| padding: 10px; | |
| margin: 5px 0; | |
| border-radius: 8px; | |
| font-size: 13px; | |
| word-break: break-all; | |
| } | |
| .status-item.success { background: rgba(46,213,115,0.2); border-left: 3px solid #2ed573; } | |
| .status-item.error { background: rgba(255,71,87,0.2); border-left: 3px solid #ff4757; } | |
| .status-item.pending { background: rgba(255,255,255,0.1); border-left: 3px solid #feca57; } | |
| .progress-bar { | |
| height: 4px; | |
| background: rgba(255,255,255,0.1); | |
| border-radius: 2px; | |
| margin-top: 15px; | |
| overflow: hidden; | |
| } | |
| .progress-fill { | |
| height: 100%; | |
| background: linear-gradient(90deg, #ff6b6b, #feca57); | |
| transition: width 0.3s; | |
| width: 0%; | |
| } | |
| .stats { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-top: 10px; | |
| font-size: 13px; | |
| color: #888; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Video Downloader</h1> | |
| <p class="subtitle">Paste video links below, one per line</p> | |
| <div class="card"> | |
| <textarea id="links" placeholder="https://example.com/video1 | |
| https://example.com/video2 | |
| https://example.com/video3"></textarea> | |
| <button class="btn" id="downloadBtn" onclick="startDownload()">Download All</button> | |
| <div id="status"> | |
| <div class="progress-bar"><div class="progress-fill" id="progress"></div></div> | |
| <div class="stats"> | |
| <span id="statsText">0 / 0 completed</span> | |
| <span id="statsPercent">0%</span> | |
| </div> | |
| <div id="statusList"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let pollInterval; | |
| async function startDownload() { | |
| const links = document.getElementById('links').value.trim(); | |
| if (!links) return alert('Please enter at least one link'); | |
| const btn = document.getElementById('downloadBtn'); | |
| btn.disabled = true; | |
| btn.textContent = 'Fetching videos...'; | |
| document.getElementById('status').style.display = 'block'; | |
| document.getElementById('statusList').innerHTML = ''; | |
| document.getElementById('progress').style.width = '0%'; | |
| try { | |
| const resp = await fetch('/download', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({links: links.split('\\n').filter(l => l.trim())}) | |
| }); | |
| const data = await resp.json(); | |
| pollInterval = setInterval(() => pollStatus(data.job_id), 500); | |
| } catch(e) { | |
| alert('Error: ' + e.message); | |
| btn.disabled = false; | |
| btn.textContent = 'Download All'; | |
| } | |
| } | |
| async function pollStatus(jobId) { | |
| try { | |
| const resp = await fetch('/status/' + jobId); | |
| const data = await resp.json(); | |
| const list = document.getElementById('statusList'); | |
| list.innerHTML = ''; | |
| let completed = 0; | |
| data.items.forEach(item => { | |
| const div = document.createElement('div'); | |
| div.className = 'status-item ' + item.status; | |
| div.textContent = item.url + ' - ' + item.message; | |
| list.appendChild(div); | |
| if (item.status !== 'pending') completed++; | |
| }); | |
| const percent = Math.round((completed / data.items.length) * 100); | |
| document.getElementById('progress').style.width = percent + '%'; | |
| document.getElementById('statsText').textContent = completed + ' / ' + data.items.length + ' completed'; | |
| document.getElementById('statsPercent').textContent = percent + '%'; | |
| if (data.complete) { | |
| clearInterval(pollInterval); | |
| const btn = document.getElementById('downloadBtn'); | |
| if (data.success_count > 0) { | |
| btn.textContent = 'Starting download...'; | |
| window.location.href = '/get-zip/' + jobId; | |
| setTimeout(() => { | |
| btn.disabled = false; | |
| btn.textContent = 'Download All'; | |
| }, 2000); | |
| } else { | |
| btn.disabled = false; | |
| btn.textContent = 'Download All'; | |
| } | |
| } | |
| } catch(e) { | |
| console.error(e); | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| def extract_id(url): | |
| """Extract video ID from RedGifs URL""" | |
| patterns = [ | |
| r'redgifs\.com/watch/([a-zA-Z]+)', | |
| r'redgifs\.com/ifr/([a-zA-Z]+)', | |
| r'^([a-zA-Z]+)$' | |
| ] | |
| for pattern in patterns: | |
| match = re.search(pattern, url.strip()) | |
| if match: | |
| return match.group(1).lower() | |
| return None | |
| def get_video_url(video_id): | |
| """Get direct video URL from RedGifs API""" | |
| headers = { | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', | |
| 'Accept': 'application/json', | |
| } | |
| token_resp = requests.get('https://api.redgifs.com/v2/auth/temporary', headers=headers) | |
| token = token_resp.json().get('token') | |
| if not token: | |
| raise Exception("Failed to get auth token") | |
| headers['Authorization'] = f'Bearer {token}' | |
| api_url = f'https://api.redgifs.com/v2/gifs/{video_id}' | |
| resp = requests.get(api_url, headers=headers) | |
| if resp.status_code != 200: | |
| raise Exception(f"API error: {resp.status_code}") | |
| data = resp.json() | |
| urls = data.get('gif', {}).get('urls', {}) | |
| return urls.get('hd') or urls.get('sd') | |
| def download_video(url, job_id, index): | |
| """Download a single video and store in memory""" | |
| video_id = extract_id(url) | |
| with jobs_lock: | |
| jobs[job_id]['items'][index] = { | |
| 'url': url, | |
| 'status': 'pending', | |
| 'message': 'Processing...' | |
| } | |
| if not video_id: | |
| with jobs_lock: | |
| jobs[job_id]['items'][index] = { | |
| 'url': url, | |
| 'status': 'error', | |
| 'message': 'Invalid URL format' | |
| } | |
| return | |
| try: | |
| video_url = get_video_url(video_id) | |
| if not video_url: | |
| raise Exception("No video URL found") | |
| resp = requests.get(video_url) | |
| resp.raise_for_status() | |
| with jobs_lock: | |
| jobs[job_id]['videos'][video_id] = resp.content | |
| jobs[job_id]['items'][index] = { | |
| 'url': url, | |
| 'status': 'success', | |
| 'message': f'Ready: {video_id}.mp4' | |
| } | |
| except Exception as e: | |
| with jobs_lock: | |
| jobs[job_id]['items'][index] = { | |
| 'url': url, | |
| 'status': 'error', | |
| 'message': str(e) | |
| } | |
| def index(): | |
| return render_template_string(HTML_TEMPLATE) | |
| def download(): | |
| data = request.json | |
| links = data.get('links', []) | |
| job_id = str(uuid.uuid4()) | |
| with jobs_lock: | |
| jobs[job_id] = { | |
| 'items': [{ | |
| 'url': link, | |
| 'status': 'pending', | |
| 'message': 'Queued...' | |
| } for link in links], | |
| 'videos': {}, | |
| 'complete': False | |
| } | |
| def run_downloads(): | |
| with ThreadPoolExecutor(max_workers=3) as executor: | |
| futures = [] | |
| for i, link in enumerate(links): | |
| futures.append(executor.submit(download_video, link, job_id, i)) | |
| for f in futures: | |
| f.result() | |
| with jobs_lock: | |
| jobs[job_id]['complete'] = True | |
| threading.Thread(target=run_downloads, daemon=True).start() | |
| return jsonify({'job_id': job_id}) | |
| def status(job_id): | |
| with jobs_lock: | |
| job = jobs.get(job_id, {'items': [], 'complete': True, 'videos': {}}) | |
| success_count = len(job.get('videos', {})) | |
| return jsonify({ | |
| 'items': job['items'], | |
| 'complete': job['complete'], | |
| 'success_count': success_count | |
| }) | |
| def get_zip(job_id): | |
| with jobs_lock: | |
| job = jobs.get(job_id) | |
| if not job or not job['videos']: | |
| return "No videos to download", 404 | |
| zip_buffer = io.BytesIO() | |
| with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf: | |
| for video_id, content in job['videos'].items(): | |
| zf.writestr(f'{video_id}.mp4', content) | |
| # Clean up job after download | |
| del jobs[job_id] | |
| zip_buffer.seek(0) | |
| return send_file( | |
| zip_buffer, | |
| mimetype='application/zip', | |
| as_attachment=True, | |
| download_name='redgifs_download.zip' | |
| ) | |
| if __name__ == '__main__': | |
| port = int(os.environ.get('PORT', 7860)) | |
| print(f"\n Video Downloader running at http://localhost:{port}\n") | |
| app.run(host='0.0.0.0', port=port, debug=False) | |