|
|
""" |
|
|
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__) |
|
|
|
|
|
|
|
|
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) |
|
|
} |
|
|
|
|
|
@app.route('/') |
|
|
def index(): |
|
|
return render_template_string(HTML_TEMPLATE) |
|
|
|
|
|
@app.route('/download', methods=['POST']) |
|
|
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}) |
|
|
|
|
|
@app.route('/status/<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 |
|
|
}) |
|
|
|
|
|
@app.route('/get-zip/<job_id>') |
|
|
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) |
|
|
|
|
|
|
|
|
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) |
|
|
|