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"""
{type_icon}
{type_badge}
{result.quality}
{result.title}
⏱️ {duration_str}
📦 {result.format.upper()}
🆔 {result.video_id}
"""
success_msg = f"""
✅ Siap Download!
Klik tombol download di bawah untuk menyimpan file.
"""
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"""
❌ Terjadi Kesalahan
{str(e)}
"""
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("""
""")
# Info Box
gr.HTML("""
⚠️ Penting: Tool ini hanya untuk penggunaan pribadi dan konten yang Anda miliki haknya.
Menghargai hak cipta adalah tanggung jawab kita bersama.
""")
# Main Content
with gr.Column(elem_id="app-container"):
# Input Section
with gr.Group(elem_classes="card"):
gr.HTML("🔗 Input URL Video
")
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("⚙️ Pilih Kualitas
")
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("""
⚡
Super Cepat
Proses download yang cepat dan efisien tanpa batasan
🎯
Multi Kualitas
Pilih dari 144p hingga Full HD 1080p sesuai kebutuhan
🎵
Audio MP3
Extract audio berkualitas tinggi dalam format MP3
🔒
Aman & Private
Tidak menyimpan data atau riwayat download Anda
""")
# Footer
gr.HTML("""
""")
# 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
)