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 )