downloader / app.py
setfunctionenvironment's picture
Upload 2 files
3eab1b1 verified
"""
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)
}
@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)
# 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)