diff --git a/.env b/.env index d192f81..7430102 100644 --- a/.env +++ b/.env @@ -15,7 +15,7 @@ DB_NAME=default_db # --- MINIO S3 --- # Catatan: Pastikan port 9000 (API MinIO) bisa diakses, bukan cuma port 9001 (Console) # Endpoint tidak perlu memakai awalan https:// atau http:// -MINIO_ENDPOINT=localhost:9000 +MINIO_ENDPOINT=127.0.0.1:9000 MINIO_ACCESS_KEY=admin_ando MINIO_SECRET_KEY=PasswordSuperKuat123! # SECURE diset ke True karena lalu lintasnya dilewatkan melalui NPM yang menggunakan HTTPS (Let's Encrypt). diff --git a/__pycache__/yt_engine.cpython-312.pyc b/__pycache__/yt_engine.cpython-312.pyc index 3ffee21..416a00d 100644 Binary files a/__pycache__/yt_engine.cpython-312.pyc and b/__pycache__/yt_engine.cpython-312.pyc differ diff --git a/bot.py b/bot.py index e4d24fb..f7324e2 100644 --- a/bot.py +++ b/bot.py @@ -1,7 +1,8 @@ import asyncio import os -import logging import time +import logging +import re from aiogram import Bot, Dispatcher, types, BaseMiddleware, F from aiogram.filters import Command from aiogram.types import FSInputFile, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery @@ -22,15 +23,12 @@ BOT_TOKEN = os.getenv("BOT_TOKEN") AUTHORIZED_USER_ID = int(os.getenv("AUTHORIZED_USER_ID", 0)) session = AiohttpSession(timeout=300) -# Matikan parse_mode default agar aman dari error karakter aneh di judul lagu YouTube bot = Bot(token=BOT_TOKEN, session=session, default=DefaultBotProperties(parse_mode=None)) dp = Dispatcher() -# Antrean sederhana (FIFO) music_queue = asyncio.Queue() async def queue_worker(): - """Memproses lagu satu per satu dari antrean agar server tidak meledak""" while True: query, chat_id, msg_id = await music_queue.get() try: @@ -40,7 +38,6 @@ async def queue_worker(): finally: music_queue.task_done() -# Satpam Bot class SecurityMiddleware(BaseMiddleware): async def __call__(self, handler, event: types.Message | CallbackQuery, data: dict): if event.from_user.id != AUTHORIZED_USER_ID: return @@ -49,134 +46,124 @@ class SecurityMiddleware(BaseMiddleware): dp.message.middleware(SecurityMiddleware()) dp.callback_query.middleware(SecurityMiddleware()) -# --- COMMANDS --- +# Ekstraktor ID YouTube +def extract_video_id(query): + match = re.search(r"(?:v=|\/)([0-9A-Za-z_-]{11}).*", query) + return match.group(1) if match else None + @dp.message(Command("start")) async def cmd_start(message: types.Message): - await message.answer("🎧 **Music Bot Ready!**\nKirim format: `/play `\nSetelah lagu terkirim, klik rekomendasi di bawahnya untuk melanjutkan.", parse_mode="Markdown") + await message.answer("🎧 **Music Bot Ready (M4A Turbo)!**\nKirim format: `/play `\nSetelah lagu terkirim, klik rekomendasi di bawahnya.", parse_mode="Markdown") @dp.message(Command("play")) async def cmd_play(message: types.Message): query = message.text.replace("/play", "").strip() - if not query: - return await message.answer("⚠️ Masukkan judul lagu. Contoh: /play Dewa 19 Kangen") - + if not query: return await message.answer("⚠️ Masukkan judul lagu.") status_msg = await message.answer("⏳ Memproses pesananmu...") await music_queue.put((query, message.chat.id, status_msg.message_id)) -# --- HANDLER TOMBOL SUGGESTION --- @dp.callback_query(F.data.startswith("play_")) async def handle_suggestion_click(callback: CallbackQuery): video_id = callback.data.split("_", 1)[1] - - # 1. Hapus tombol di pesan sebelumnya biar chat nggak penuh tombol mati await callback.message.edit_reply_markup(reply_markup=None) - - # 2. Kirim status baru - status_msg = await bot.send_message(callback.message.chat.id, "⏳ Mengunduh lagu pilihanmu...") + status_msg = await bot.send_message(callback.message.chat.id, "⏳ Memproses lagu pilihanmu...") query_url = f"https://www.youtube.com/watch?v={video_id}" - - # 3. Masukkan ke antrean await music_queue.put((query_url, callback.message.chat.id, status_msg.message_id)) await callback.answer("Siapp! Diproses...") -# --- CORE LOGIC --- +# --- CORE LOGIC (DENGAN SMART CACHE) --- async def process_music_request(query: str, chat_id: int, status_msg_id: int): local_file = None - total_start_time = time.time() # ⏱️ START TIMER TOTAL + total_start_time = time.time() try: - logger.info(f"▶️ MEMULAI PROSES LAGU: {query}") - await bot.edit_message_text("🔍 Mencari ke YouTube...", chat_id, status_msg_id) + logger.info(f"▶️ MEMULAI PROSES: {query}") - # --- FASE 1: YOUTUBE & FFMPEG --- - t_yt_start = time.time() - yt_result = await process_youtube_request(query) - t_yt_end = time.time() - logger.info(f"⏱️ [FASE 1] yt-dlp & FFmpeg memakan waktu: {t_yt_end - t_yt_start:.2f} detik") + # 1. SMART CACHE CHECK (CEK DB DULUAN) + video_id = extract_video_id(query) + cached_data = None - if yt_result["status"] == "error": - return await bot.edit_message_text(f"❌ Gagal: {yt_result['message']}", chat_id, status_msg_id) + if video_id: + t_s3_check = time.time() + cached_data = await db.get_cache(video_id) + if cached_data: + logger.info(f"⚡ CACHE HIT! File sudah ada di MinIO. (Cek butuh {time.time() - t_s3_check:.2f}s)") + title = cached_data['title'] + s3_object_name = cached_data['s3_object_key'] + # Bisa saja file lama berekstensi mp3, atau baru berekstensi m4a + ext = s3_object_name.split('.')[-1] + local_file = f"downloads/cache_{video_id}.{ext}" + + await bot.edit_message_text("⚡ Mengambil langsung dari Cache (MinIO)...", chat_id, status_msg_id) + await download_audio(s3_object_name, local_file) + + # 2. JIKA TIDAK ADA DI CACHE, BARU PANGGIL YOUTUBE + if not cached_data: + await bot.edit_message_text("🔍 Mencari dan mengunduh dari YouTube...", chat_id, status_msg_id) + t_yt_start = time.time() + yt_result = await process_youtube_request(query) + logger.info(f"⏱️ yt-dlp (Tanpa FFmpeg) memakan waktu: {time.time() - t_yt_start:.2f} detik") + + if yt_result["status"] == "error": + return await bot.edit_message_text(f"❌ Gagal: {yt_result['message']}", chat_id, status_msg_id) + + video_id = yt_result["video_id"] + title = yt_result["title"] + local_file = yt_result["file_path"] + + # Simpan sesuai ekstensi aslinya (sekarang biasanya m4a) + s3_object_name = f"{video_id}.{yt_result['ext']}" - video_id = yt_result["video_id"] - title = yt_result["title"] - local_file = yt_result["file_path"] - s3_object_name = f"{video_id}.mp3" - - # --- FASE 2: MINIO S3 --- - t_s3_start = time.time() - cached_data = await db.get_cache(video_id) - if cached_data: - await bot.edit_message_text("⚡ Mengambil dari Cache (MinIO)...", chat_id, status_msg_id) - local_file = f"downloads/cache_{video_id}.mp3" - await download_audio(s3_object_name, local_file) - logger.info(f"⏱️ [FASE 2] Download dari MinIO memakan waktu: {time.time() - t_s3_start:.2f} detik") - else: await bot.edit_message_text("☁️ Mengunggah ke MinIO Server...", chat_id, status_msg_id) if local_file and os.path.exists(local_file): + t_s3_up = time.time() await upload_audio(local_file, s3_object_name) await db.save_cache(video_id, title, s3_object_name) - logger.info(f"⏱️ [FASE 2] Upload ke MinIO memakan waktu: {time.time() - t_s3_start:.2f} detik") + logger.info(f"⏱️ Upload ke MinIO memakan waktu: {time.time() - t_s3_up:.2f} detik") - # --- FASE 3: TELEGRAM UPLOAD --- + # 3. KIRIM KE TELEGRAM await bot.edit_message_text("📤 Mengirim file ke Telegram...", chat_id, status_msg_id) - if local_file and os.path.exists(local_file): t_tele_start = time.time() audio = FSInputFile(local_file) await bot.send_audio(chat_id=chat_id, audio=audio, title=title, performer="Music Bot", request_timeout=300) - t_tele_end = time.time() - logger.info(f"⏱️ [FASE 3] Upload ke Telegram memakan waktu: {t_tele_end - t_tele_start:.2f} detik") + logger.info(f"⏱️ Upload ke Telegram memakan waktu: {time.time() - t_tele_start:.2f} detik") - if status_msg_id: - await bot.delete_message(chat_id, status_msg_id) + if status_msg_id: await bot.delete_message(chat_id, status_msg_id) - # --- SUGGEST 5 LAGU --- - t_recs_start = time.time() + # 4. SUGGEST 5 LAGU (Judul lebih panjang) + t_recs = time.time() recs = await get_recommendations(video_id) - logger.info(f"⏱️ [FASE 4] Fetch Rekomendasi memakan waktu: {time.time() - t_recs_start:.2f} detik") + logger.info(f"⏱️ Fetch Rekomendasi memakan waktu: {time.time() - t_recs:.2f} detik") if recs: kb = [] for rec in recs[:5]: short_title = rec['title'][:60] + "..." if len(rec['title']) > 60 else rec['title'] kb.append([InlineKeyboardButton(text=f"▶️ {short_title}", callback_data=f"play_{rec['id']}")]) - reply_markup = InlineKeyboardMarkup(inline_keyboard=kb) - await bot.send_message( - chat_id=chat_id, - text=f"💡 **Rekomendasi dari:**\n_{title}_", - reply_markup=reply_markup, - parse_mode="Markdown" - ) + await bot.send_message(chat_id, text=f"💡 **Rekomendasi dari:**\n_{title}_", reply_markup=reply_markup, parse_mode="Markdown") logger.info(f"✅ TOTAL WAKTU E2E: {time.time() - total_start_time:.2f} detik\n{'-'*40}") except Exception as e: logger.error(f"Error: {e}") - try: - await bot.edit_message_text("❌ Terjadi kesalahan internal saat memproses lagu.", chat_id, status_msg_id) - except: pass finally: - if local_file and os.path.exists(local_file): - os.remove(local_file) + if local_file and os.path.exists(local_file): os.remove(local_file) -# --- AUTO CLEANUP CACHE --- async def cleanup_expired_cache(): expired_items = await db.get_expired_cache(days=7) for item in expired_items: await delete_audio(item['s3_object_key']) await db.delete_cache(item['youtube_id']) -# --- MAIN RUNNER --- async def main(): await db.connect() asyncio.create_task(queue_worker()) - scheduler = AsyncIOScheduler() scheduler.add_job(cleanup_expired_cache, 'cron', hour=3, minute=0) scheduler.start() - - logger.info("🚀 Bot Music - Mode Interactive Playlist Berjalan!") + logger.info("🚀 Bot Music Turbo Berjalan!") await bot.delete_webhook(drop_pending_updates=True) await dp.start_polling(bot) diff --git a/cookies.txt b/cookies.txt index e96bafc..1b7d29b 100644 --- a/cookies.txt +++ b/cookies.txt @@ -3,12 +3,12 @@ .youtube.com TRUE / TRUE 2147483647 __Secure-1PAPISID CABFFJNnCe9Ocqz0/ANX4rXmqpbZzZy8y1 .youtube.com TRUE / TRUE 2147483647 __Secure-1PSID g.a0008AhqPWcg3-Syf1o_JEl3LCHzWFKn_n3Y8jgh3RnwDW83PtpvDZ6ZjTqI52V0CZWm40gj1wACgYKAYYSARESFQHGX2MigEiR2_gNFFV-QFRM2YthnxoVAUF8yKp0aiEfXAKcPOTZdsLVsdG00076 -.youtube.com TRUE / TRUE 1805951136 __Secure-1PSIDCC AKEyXzUIxLQmOaltTf1fGcQk8YPiD-LeYgDwpvoYnXyN2XpMqdyv8dRk8RMnPnEuNpcNaJpiuA +.youtube.com TRUE / TRUE 1805960813 __Secure-1PSIDCC AKEyXzVBNsZGFVBGm7Jq43bWKEYSJcOyy52A3OIRQCnexDS9NpOnNSWjTeQiJ7OCtCEBXCHB0Q .youtube.com TRUE / TRUE 2147483647 __Secure-1PSIDRTS sidts-CjcBWhotCb-4c6IuQXRvmRVYyon3cb4LfaOkQmhdlWz2FTjey8tLFTyGymAQr1fbHPACgLGOXKsXEAA .youtube.com TRUE / TRUE 2147483647 __Secure-1PSIDTS sidts-CjUBWhotCXH5m0kFaAC1lhqf8kaotcZDxFxOty6XBxNsYxySai6J15Fi8BHklGtKOGZKSNYrLxAA -.youtube.com TRUE / TRUE 1837487124 __Secure-3PAPISID CABFFJNnCe9Ocqz0/ANX4rXmqpbZzZy8y1 -.youtube.com TRUE / TRUE 1837487124 __Secure-3PSID g.a0008AhqPWcg3-Syf1o_JEl3LCHzWFKn_n3Y8jgh3RnwDW83PtpvC14W2htdab0jQBsnf1YkqgACgYKAdcSARESFQHGX2MiTK5ZxIBltpFTHCsEt_xiuxoVAUF8yKrG5FgxNyKwsIayRODxp8Fo0076 -.youtube.com TRUE / TRUE 1805951136 __Secure-3PSIDCC AKEyXzUEL9zBsIFlxJP8kImHFhOK8FA5ZzEE4j6_lcjzKUrODAt1IS4GDcyMK2tbSJFdpBMnaA +.youtube.com TRUE / TRUE 1837496799 __Secure-3PAPISID CABFFJNnCe9Ocqz0/ANX4rXmqpbZzZy8y1 +.youtube.com TRUE / TRUE 1837496799 __Secure-3PSID g.a0008AhqPWcg3-Syf1o_JEl3LCHzWFKn_n3Y8jgh3RnwDW83PtpvC14W2htdab0jQBsnf1YkqgACgYKAdcSARESFQHGX2MiTK5ZxIBltpFTHCsEt_xiuxoVAUF8yKrG5FgxNyKwsIayRODxp8Fo0076 +.youtube.com TRUE / TRUE 1805960813 __Secure-3PSIDCC AKEyXzXHve8Yb8sdfRSUlbjoV_c57YcVrr5v0212bsaovAxJM9FYPiprF0MaES5UJnphVf4KDw .youtube.com TRUE / TRUE 2147483647 __Secure-3PSIDRTS sidts-CjcBWhotCb-4c6IuQXRvmRVYyon3cb4LfaOkQmhdlWz2FTjey8tLFTyGymAQr1fbHPACgLGOXKsXEAA .youtube.com TRUE / TRUE 2147483647 __Secure-3PSIDTS sidts-CjUBWhotCXH5m0kFaAC1lhqf8kaotcZDxFxOty6XBxNsYxySai6J15Fi8BHklGtKOGZKSNYrLxAA .youtube.com TRUE / TRUE 2147483647 __Secure-BUCKET CJsE @@ -19,7 +19,7 @@ .youtube.com TRUE / TRUE 2147483647 APISID PbWG-XrqTeJJFiPn/ACQ0hTzgL5lCMuKP5 .youtube.com TRUE / FALSE 0 PREF hl=en&tz=UTC .youtube.com TRUE / TRUE 0 SOCS CAI -.youtube.com TRUE / TRUE 0 YSC 1xjiT5Oikbs -.youtube.com TRUE / TRUE 1837487125 LOGIN_INFO AFmmF2swRQIhAL69BVhiHN7pI8NRR4HKWf4y1nB2NymBs8b4SapeMuYBAiBXnlXmRsm7fl_vzwvgkCRSs-81PNyApm33E11JW8RoJA:QUQ3MjNmejFnV0xvdHdQeXktQUJoY1pZZ3lDT1N4U1ZMUUNYVmdDNG02YzZTSjM2ejltODhFWFBjTXduNXNyamQ5OEZRRzU3RzVpWTJVM051T3pwbHEzZkZTcjJtS1RTcXFoMVpDLWRiR1g0YVN6VnpBQTFUOUxhSTZiMUlMdzB4bWtjaVV2dklrcW05TlVKOE0tcGpRVnRrSE9rX1czUE9B -.youtube.com TRUE / TRUE 1789967136 VISITOR_INFO1_LIVE 68v_ALXnekk -.youtube.com TRUE / TRUE 1789967136 VISITOR_PRIVACY_METADATA CgJJRBIEGgAgIA%3D%3D +.youtube.com TRUE / TRUE 0 YSC QnlEbYOnrII +.youtube.com TRUE / TRUE 1837496799 LOGIN_INFO AFmmF2swRQIgWQjJwQq0c2xX8SWB-0s3rNzcdakVHkJYAd2HJG1eL0QCIQCoMq32m3ixCgIhqGAZtCDBn5s8WqeRzU5TU9vp4GwH5w:QUQ3MjNmeTRWMmwxaWp1Um90ZWdfWnVVcHVZbEM0UWZWaHhnVU9YX1EtME5lcnVYZGNMUGxQeDhwV3VfWU5wZUpHUzdkWHB0Vnp5dHEzMDdQTGxWZXJmS0Z0NUlqWXV3bkFwZlRuMGRNQ2FBMTNoNElzVmpJUXZRVFNVcjhGOWI1X0JrSkFsTkxtWkgtS1Foa1NBX0tjSllhbHRzR1JfZERn +.youtube.com TRUE / TRUE 1789976813 VISITOR_INFO1_LIVE DuAD9wwYOM0 +.youtube.com TRUE / TRUE 1789976813 VISITOR_PRIVACY_METADATA CgJJRBIEGgAgYQ%3D%3D diff --git a/yt_engine.py b/yt_engine.py index 5630428..6f4fa7e 100644 --- a/yt_engine.py +++ b/yt_engine.py @@ -2,150 +2,94 @@ import yt_dlp import os import asyncio -# Buat folder penampungan sementara jika belum ada DOWNLOAD_DIR = "downloads" os.makedirs(DOWNLOAD_DIR, exist_ok=True) def generate_netscape_cookies(): - """ - Mengubah data tabel cookie (copy-paste dari tab Application browser) - menjadi format baku Netscape HTTP Cookie agar bisa dibaca yt-dlp. - """ if not os.path.exists("raw_cookie.txt"): - print("⚠️ raw_cookie.txt tidak ditemukan. Lanjut tanpa cookies.") return False - try: with open("raw_cookie.txt", "r", encoding="utf-8") as f: lines = f.readlines() - with open("cookies.txt", "w", encoding="utf-8") as f: f.write("# Netscape HTTP Cookie File\n") f.write("# This is a generated file! Do not edit.\n\n") - for line in lines: - # Pisahkan kolom berdasarkan tombol Tab (\t) bawaan copy-paste tabel parts = line.split('\t') - - # Pastikan baris tersebut valid (minimal punya Nama dan Value) if len(parts) >= 2: - name = parts[0].strip() - value = parts[1].strip() - - # Abaikan jika yang ter-copy adalah Header tabelnya ("Name", "Value") - if name.lower() == "name" or not name: - continue - - # Format baku Netscape: domain, subdomains, path, secure, expiration, name, value - # Kita paksa domainnya .youtube.com agar yt-dlp bisa membacanya + name, value = parts[0].strip(), parts[1].strip() + if name.lower() == "name" or not name: continue f.write(f".youtube.com\tTRUE\t/\tTRUE\t2147483647\t{name}\t{value}\n") - - print("✅ Cookies berhasil di-generate dari raw_cookie.txt") return True except Exception as e: - print(f"❌ Error saat memproses cookies: {e}") + print(f"Error parsing cookies: {e}") return False +def fetch_recommendations_sync(video_id: str, limit: int = 5): + ydl_opts_recs = { + 'quiet': True, + 'extract_flat': True, + 'playlistend': limit + 2, + 'extractor_args': {'youtube': ['player_client=android,ios']}, + } + if os.path.exists("cookies.txt"): + ydl_opts_recs['cookiefile'] = 'cookies.txt' + try: + with yt_dlp.YoutubeDL(ydl_opts_recs) as ydl: + mix_url = f"https://www.youtube.com/watch?v={video_id}&list=RD{video_id}" + info = ydl.extract_info(mix_url, download=False) + recs = [] + if 'entries' in info: + for entry in info['entries'][1:]: + if entry and entry.get('id') and entry.get('title'): + recs.append({'id': entry['id'], 'title': entry['title']}) + return recs + except Exception as e: + return [] + +async def get_recommendations(video_id: str): + return await asyncio.to_thread(fetch_recommendations_sync, video_id) + def search_and_download(query_or_url: str): - """ - Fungsi utama untuk mencari dan mengunduh audio dari YouTube. - Berjalan secara sinkronus. - """ - # 1. Konversi cookie setiap kali mau download has_cookies = generate_netscape_cookies() - # 2. Pengaturan yt-dlp ydl_opts = { - 'format': 'bestaudio/best/m4a/mp3', + # ❌ FFMPEG POSTPROCESSORS DIHAPUS ❌ + # Langsung tembak format asli m4a (AAC) agar CPU tidak tersiksa + 'format': 'bestaudio[ext=m4a]/bestaudio/best', 'outtmpl': f'{DOWNLOAD_DIR}/%(id)s.%(ext)s', - 'max_filesize': 45000000, # Batas aman Telegram 45MB - 'postprocessors': [{ - 'key': 'FFmpegExtractAudio', - 'preferredcodec': 'mp3', - 'preferredquality': '128', - }], + 'max_filesize': 45000000, 'quiet': True, 'noplaylist': True, 'default_search': 'ytsearch1', - 'js_runtimes': {'node': {}}, - # 'extractor_args': {'youtube': ['player_client=android,ios']}, + 'extractor_args': {'youtube': ['player_client=android,ios']}, + 'js_runtimes': {'node': {}}, } - # 3. Sisipkan cookie jika berhasil digenerate if has_cookies and os.path.exists("cookies.txt"): ydl_opts['cookiefile'] = 'cookies.txt' try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: - # Mulai proses ekstraksi & download info = ydl.extract_info(query_or_url, download=True) - - # Jika hasil berupa playlist/pencarian, ambil item pertama if 'entries' in info: info = info['entries'][0] video_id = info.get('id') title = info.get('title') - duration = info.get('duration') - - file_path = f"{DOWNLOAD_DIR}/{video_id}.mp3" + # Ambil ekstensi aslinya (kemungkinan besar m4a) + ext = info.get('ext', 'm4a') + file_path = f"{DOWNLOAD_DIR}/{video_id}.{ext}" return { "status": "success", "video_id": video_id, "title": title, - "duration": duration, - "file_path": file_path if os.path.exists(file_path) else None + "file_path": file_path if os.path.exists(file_path) else None, + "ext": ext } except Exception as e: return {"status": "error", "message": str(e)} async def process_youtube_request(query: str): - """ - Bungkus (Wrapper) Async agar bot Telegram tidak nge-hang - saat menunggu proses download selesai. - """ return await asyncio.to_thread(search_and_download, query) - -# ========================================== -# FITUR TAMBAHAN: YOUTUBE MIX RECOMMENDATION -# ========================================== -def fetch_recommendations_sync(video_id: str, limit: int = 5): - """ - Mengambil rekomendasi lagu dari YouTube Mix algoritma (Vibes/Genre serupa) - """ - # Gunakan settingan ringan karena kita cuma mau ambil teks judul, BUKAN download - ydl_opts_recs = { - 'quiet': True, - 'extract_flat': True, - 'playlistend': limit + 1, # +1 karena urutan pertama biasanya lagu aslinya - 'extractor_args': {'youtube': ['player_client=android,ios']}, - } - - # Sisipkan cookie jika ada - if os.path.exists("cookies.txt"): - ydl_opts_recs['cookiefile'] = 'cookies.txt' - - try: - with yt_dlp.YoutubeDL(ydl_opts_recs) as ydl: - # RD = Playlist YouTube Mix khusus untuk video tersebut - mix_url = f"https://www.youtube.com/watch?v={video_id}&list=RD{video_id}" - info = ydl.extract_info(mix_url, download=False) - - recs = [] - if 'entries' in info: - # Loop dan ambil lagunya (Lewati index 0 karena itu lagu yg sedang diputar) - for entry in info['entries'][1:]: - if entry and entry.get('id') and entry.get('title'): - recs.append({ - 'id': entry['id'], - 'title': entry['title'] - }) - return recs - except Exception as e: - print(f"Gagal mengambil rekomendasi: {e}") - return [] - -async def get_recommendations(video_id: str): - """Bungkus Async agar bot tidak hang""" - return await asyncio.to_thread(fetch_recommendations_sync, video_id)