Mxltic's picture
Update app.py
b116835 verified
import asyncio
import os
import re
import json
from dataclasses import dataclass
from typing import List, Optional, Dict, Any
from urllib.parse import urlparse, parse_qs
import aiohttp
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
import base64
import gradio as gr
@dataclass
class VideoResult:
title: str
type: str
format: str
thumbnail: str
download_url: str
video_id: str
duration: int
quality: str
class SaveTubeDownloader:
API_BASE = "https://media.savetube.me/api"
HEADERS = {
'accept': '*/*',
'content-type': 'application/json',
'origin': 'https://yt.savetube.me',
'referer': 'https://yt.savetube.me/',
'user-agent': 'Postify/1.0.0'
}
FORMATS = ['144', '240', '360', '480', '720', '1080', 'mp3']
SECRET_KEY = 'C5D58EF67A7584E4A29F6C35BBC4EB12'
@staticmethod
def extract_youtube_id(url: str) -> Optional[str]:
"""Extract YouTube video ID from URL"""
patterns = [
r'youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})',
r'youtube\.com\/embed\/([a-zA-Z0-9_-]{11})',
r'youtube\.com\/v\/([a-zA-Z0-9_-]{11})',
r'youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})',
r'youtu\.be\/([a-zA-Z0-9_-]{11})'
]
for pattern in patterns:
match = re.search(pattern, url)
if match:
return match.group(1)
return None
@staticmethod
def decrypt_data(encrypted: str) -> Dict[str, Any]:
"""Decrypt API response"""
try:
data = base64.b64decode(encrypted)
iv = data[:16]
content = data[16:]
key = bytes.fromhex(SaveTubeDownloader.SECRET_KEY)
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
decryptor = cipher.decryptor()
decrypted = decryptor.update(content) + decryptor.finalize()
# Remove padding
padding_length = decrypted[-1]
decrypted = decrypted[:-padding_length]
return json.loads(decrypted.decode())
except Exception as e:
raise RuntimeError(f"Decryption failed: {e}")
async def get_cdn(self) -> str:
"""Get CDN URL"""
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.API_BASE}/random-cdn",
headers=self.HEADERS
) as response:
if response.status == 200:
data = await response.json()
return data.get('cdn')
raise RuntimeError("Failed to get CDN")
async def download(self, url: str, format: str = '720') -> VideoResult:
"""Download video from YouTube"""
# Validate URL
video_id = self.extract_youtube_id(url)
if not video_id:
raise ValueError("URL YouTube tidak valid. Pastikan URL benar!")
if format not in self.FORMATS:
raise ValueError(f"Format tidak valid. Pilih: {', '.join(self.FORMATS)}")
try:
# Get CDN
cdn = await self.get_cdn()
async with aiohttp.ClientSession() as session:
# Get video info
async with session.post(
f"https://{cdn}/api/v2/info",
json={"url": f"https://www.youtube.com/watch?v={video_id}"},
headers=self.HEADERS
) as response:
if response.status != 200:
raise RuntimeError("Gagal mendapatkan info video")
result = await response.json()
decrypted = self.decrypt_data(result['data'])
# Get download link
async with session.post(
f"https://{cdn}/api/download",
json={
"id": video_id,
"downloadType": "audio" if format == 'mp3' else "video",
"quality": "128" if format == 'mp3' else format,
"key": decrypted['key']
},
headers=self.HEADERS
) as response:
if response.status != 200:
raise RuntimeError("Gagal mendapatkan link download")
dl_result = await response.json()
return VideoResult(
title=decrypted.get('title', 'Unknown'),
type='audio' if format == 'mp3' else 'video',
format=format,
thumbnail=decrypted.get('thumbnail', f'https://i.ytimg.com/vi/{video_id}/maxresdefault.jpg'),
download_url=dl_result['data']['downloadUrl'],
video_id=video_id,
duration=decrypted.get('duration', 0),
quality="128kbps" if format == 'mp3' else f"{format}p"
)
except Exception as e:
raise RuntimeError(f"Error: {str(e)}")
def format_duration(seconds: int) -> str:
"""Format duration to MM:SS or HH:MM:SS"""
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
if hours > 0:
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
async def process_download(url: str, quality: str):
"""Process download request"""
if not url.strip():
return None, None, "⚠️ Masukkan URL video terlebih dahulu!", gr.update(visible=False), gr.update(visible=False)
try:
downloader = SaveTubeDownloader()
result = await downloader.download(url, quality)
# Create result card HTML
duration_str = format_duration(result.duration)
type_icon = "🎡" if result.type == "audio" else "🎬"
type_badge = "AUDIO" if result.type == "audio" else "VIDEO"
result_html = f"""
<div style='background: white; border-radius: 16px; padding: 24px; box-shadow: 0 4px 20px rgba(0,0,0,0.08);'>
<div style='display: flex; gap: 20px; align-items: start;'>
<img src='{result.thumbnail}'
style='width: 200px; height: 112px; object-fit: cover; border-radius: 12px; flex-shrink: 0;'
onerror="this.src='https://via.placeholder.com/200x112?text=No+Image'">
<div style='flex: 1;'>
<div style='display: flex; align-items: center; gap: 8px; margin-bottom: 12px;'>
<span style='font-size: 24px;'>{type_icon}</span>
<span style='background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; padding: 4px 12px; border-radius: 6px;
font-size: 12px; font-weight: 600;'>{type_badge}</span>
<span style='background: #f3f4f6; color: #374151; padding: 4px 12px;
border-radius: 6px; font-size: 12px; font-weight: 600;'>{result.quality}</span>
</div>
<h3 style='margin: 0 0 12px 0; font-size: 18px; color: #1f2937; line-height: 1.4;'>{result.title}</h3>
<div style='display: flex; gap: 16px; color: #6b7280; font-size: 14px; margin-bottom: 16px;'>
<span>⏱️ {duration_str}</span>
<span>πŸ“¦ {result.format.upper()}</span>
<span>πŸ†” {result.video_id}</span>
</div>
</div>
</div>
</div>
"""
success_msg = f"""
<div style='padding: 20px; background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-radius: 12px; color: white; text-align: center; box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);'>
<h3 style='margin: 0; font-size: 24px;'>βœ… Siap Download!</h3>
<p style='margin: 10px 0 0 0; font-size: 16px;'>Klik tombol download di bawah untuk menyimpan file.</p>
</div>
"""
return result_html, result.download_url, success_msg, gr.update(visible=True), gr.update(visible=True, link=result.download_url)
except Exception as e:
error_msg = f"""
<div style='padding: 20px; background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-radius: 12px; color: white; text-align: center; box-shadow: 0 4px 15px rgba(239, 68, 68, 0.3);'>
<h3 style='margin: 0; font-size: 24px;'>❌ Terjadi Kesalahan</h3>
<p style='margin: 10px 0 0 0; font-size: 14px;'>{str(e)}</p>
</div>
"""
return None, None, error_msg, gr.update(visible=False), gr.update(visible=False)
def run_download(url, quality):
return asyncio.run(process_download(url, quality))
# ----------- Custom CSS -----------
custom_css = """
:root {
--primary: #667eea;
--primary-dark: #764ba2;
--success: #10b981;
--danger: #ef4444;
}
#app-container {
max-width: 1200px;
margin: 0 auto;
}
#header {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
padding: 48px 32px;
border-radius: 20px;
margin-bottom: 32px;
text-align: center;
color: white;
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.3);
position: relative;
overflow: hidden;
}
#header::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
animation: pulse 4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 0.5; }
50% { transform: scale(1.1); opacity: 0.8; }
}
#header-content {
position: relative;
z-index: 1;
}
#header h1 {
font-size: 3em;
margin: 0 0 12px 0;
font-weight: 800;
text-shadow: 2px 4px 8px rgba(0,0,0,0.2);
}
#header p {
font-size: 1.2em;
margin: 0;
opacity: 0.95;
font-weight: 500;
}
.card {
background: white;
border-radius: 16px;
padding: 28px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
margin-bottom: 24px;
}
.card-title {
font-size: 1.3em;
font-weight: 700;
color: #1f2937;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
#url_input input {
border-radius: 12px !important;
border: 2px solid #e5e7eb !important;
padding: 14px 16px !important;
font-size: 15px !important;
transition: all 0.3s ease !important;
}
#url_input input:focus {
border-color: var(--primary) !important;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1) !important;
}
.quality-radio {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.gr-button-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%) !important;
border: none !important;
border-radius: 12px !important;
padding: 14px 32px !important;
font-weight: 700 !important;
font-size: 16px !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.3) !important;
color: white !important;
}
.gr-button-primary:hover {
transform: translateY(-2px) !important;
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.4) !important;
}
.gr-button-secondary {
background: linear-gradient(135deg, var(--success) 0%, #059669 100%) !important;
border: none !important;
border-radius: 12px !important;
padding: 14px 32px !important;
font-weight: 700 !important;
font-size: 16px !important;
transition: all 0.3s ease !important;
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3) !important;
color: white !important;
}
.gr-button-secondary:hover {
transform: translateY(-2px) !important;
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.4) !important;
}
#info-box {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
padding: 20px;
border-radius: 12px;
border-left: 5px solid #f59e0b;
margin-bottom: 24px;
color: #78350f;
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.2);
}
#feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin: 32px 0;
}
.feature-card {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
padding: 24px;
border-radius: 12px;
border: 2px solid #e2e8f0;
text-align: center;
transition: all 0.3s ease;
}
.feature-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0,0,0,0.1);
border-color: var(--primary);
}
.feature-icon {
font-size: 3em;
margin-bottom: 12px;
}
.feature-title {
font-weight: 700;
color: #1f2937;
margin-bottom: 8px;
}
.feature-desc {
color: #6b7280;
font-size: 0.9em;
line-height: 1.5;
}
#footer {
text-align: center;
padding: 32px 20px;
margin-top: 48px;
color: #9ca3af;
border-top: 2px solid #f3f4f6;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.6s ease-out;
}
"""
# ----------- Gradio UI -----------
with gr.Blocks(
title="🎬 YouTube Downloader β€” SaveTube API",
theme=gr.themes.Soft(
primary_hue="violet",
secondary_hue="purple",
neutral_hue="slate",
font=gr.themes.GoogleFont("Inter"),
radius_size="lg"
),
css=custom_css
) as demo:
# Header
gr.HTML("""
<div id="header" class="animate-in">
<div id="header-content">
<h1>🎬 YouTube Downloader</h1>
<p>Download Video & Audio YouTube dengan Kualitas Terbaik</p>
</div>
</div>
""")
# Info Box
gr.HTML("""
<div id="info-box" class="animate-in">
<strong>⚠️ Penting:</strong> Tool ini hanya untuk penggunaan pribadi dan konten yang Anda miliki haknya.
Menghargai hak cipta adalah tanggung jawab kita bersama.
</div>
""")
# Main Content
with gr.Column(elem_id="app-container"):
# Input Section
with gr.Group(elem_classes="card"):
gr.HTML("<div class='card-title'>πŸ”— Input URL Video</div>")
url_input = gr.Textbox(
label="",
placeholder="Paste URL YouTube di sini (contoh: https://www.youtube.com/watch?v=dQw4w9WgXcQ)",
elem_id="url_input",
scale=1,
show_label=False
)
# Quality Selection
with gr.Group(elem_classes="card"):
gr.HTML("<div class='card-title'>βš™οΈ Pilih Kualitas</div>")
with gr.Row():
quality_selector = gr.Radio(
choices=[
("🎡 Audio MP3 (128kbps)", "mp3"),
("πŸ“± 144p", "144"),
("πŸ“± 240p", "240"),
("πŸ’» 360p", "360"),
("πŸ’» 480p", "480"),
("πŸ–₯️ HD 720p", "720"),
("πŸ–₯️ Full HD 1080p", "1080")
],
value="720",
label="",
show_label=False,
elem_classes="quality-radio"
)
# Action Button
with gr.Row():
process_btn = gr.Button(
"πŸ” Proses & Ambil Link Download",
variant="primary",
size="lg",
scale=1
)
# Status Message
status_msg = gr.HTML()
# Result Section - Simplify by removing the Column wrapper
result_card = gr.HTML(visible=False)
download_btn = gr.Button(
"⬇️ Download File",
variant="secondary",
size="lg",
link=None,
visible=False
)
# Features Grid
gr.HTML("""
<div id="feature-grid" class="animate-in">
<div class="feature-card">
<div class="feature-icon">⚑</div>
<div class="feature-title">Super Cepat</div>
<div class="feature-desc">Proses download yang cepat dan efisien tanpa batasan</div>
</div>
<div class="feature-card">
<div class="feature-icon">🎯</div>
<div class="feature-title">Multi Kualitas</div>
<div class="feature-desc">Pilih dari 144p hingga Full HD 1080p sesuai kebutuhan</div>
</div>
<div class="feature-card">
<div class="feature-icon">🎡</div>
<div class="feature-title">Audio MP3</div>
<div class="feature-desc">Extract audio berkualitas tinggi dalam format MP3</div>
</div>
<div class="feature-card">
<div class="feature-icon">πŸ”’</div>
<div class="feature-title">Aman & Private</div>
<div class="feature-desc">Tidak menyimpan data atau riwayat download Anda</div>
</div>
</div>
""")
# Footer
gr.HTML("""
<div id="footer">
<p style="font-size: 1.1em; margin-bottom: 8px;">
πŸ’‘ <strong>Tips:</strong> Untuk hasil terbaik, gunakan URL langsung dari YouTube
</p>
<p style="opacity: 0.7;">
Powered by SaveTube API β€’ Made with ❀️ using Gradio
</p>
</div>
""")
# Event Handler - SINGLE function call
process_btn.click(
fn=run_download,
inputs=[url_input, quality_selector],
outputs=[result_card, download_btn, status_msg, result_card, download_btn]
)
if __name__ == "__main__":
demo.launch(
server_name="0.0.0.0",
server_port=int(os.getenv("PORT", 7860)),
show_api=False
)