import asyncio import os 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 from aiogram.client.default import DefaultBotProperties from aiogram.client.session.aiohttp import AiohttpSession from dotenv import load_dotenv from apscheduler.schedulers.asyncio import AsyncIOScheduler # Import fungsi baru search_youtube_list from yt_engine import process_youtube_request, get_recommendations, search_youtube_list from db_manager import db from s3_manager import upload_audio, download_audio, delete_audio logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) load_dotenv() BOT_TOKEN = os.getenv("BOT_TOKEN") auth_users_str = os.getenv("AUTHORIZED_USER_IDS", "") AUTHORIZED_USER_IDS = {int(uid.strip()) for uid in auth_users_str.split(",") if uid.strip().isdigit()} session = AiohttpSession(timeout=300) bot = Bot(token=BOT_TOKEN, session=session, default=DefaultBotProperties(parse_mode=None)) dp = Dispatcher() music_queue = asyncio.Queue() # --- PENYIMPANAN SEMENTARA UNTUK PAGING --- # Format: { chat_id: {'query': 'judul', 'results': [list_lagu]} } user_searches = {} async def queue_worker(): while True: query, chat_id, msg_id = await music_queue.get() try: await process_music_request(query, chat_id, msg_id) except Exception as e: logger.error(f"Worker Error: {e}") finally: music_queue.task_done() class SecurityMiddleware(BaseMiddleware): async def __call__(self, handler, event: types.Message | CallbackQuery, data: dict): user_id = event.from_user.id if user_id not in AUTHORIZED_USER_IDS: logger.warning(f"⚠️ Akses ditolak untuk User ID: {user_id}") return return await handler(event, data) dp.message.middleware(SecurityMiddleware()) dp.callback_query.middleware(SecurityMiddleware()) def extract_video_id(query): match = re.search(r"(?:v=|\/)([0-9A-Za-z_-]{11}).*", query) return match.group(1) if match else None # --- FUNGSI PEMBUAT TOMBOL PAGING --- def get_search_keyboard(chat_id, page=0): data = user_searches.get(chat_id) if not data: return None results = data['results'] start_idx = page * 10 end_idx = start_idx + 10 page_results = results[start_idx:end_idx] kb = [] for rec in page_results: # Tampilkan Judul | Nama Channel (Biar tahu itu lagu asli atau cover) display_text = f"🎵 {rec['title']} | {rec['uploader']}" short_text = display_text[:55] + "..." if len(display_text) > 55 else display_text # Menggunakan format callback "play_" agar otomatis masuk antrean download kb.append([InlineKeyboardButton(text=short_text, callback_data=f"play_{rec['id']}")]) # Tombol Navigasi Next/Prev nav_buttons = [] if page > 0: nav_buttons.append(InlineKeyboardButton(text="⬅️ Prev", callback_data=f"page_{page-1}")) if end_idx < len(results): nav_buttons.append(InlineKeyboardButton(text="Next ➡️", callback_data=f"page_{page+1}")) if nav_buttons: kb.append(nav_buttons) return InlineKeyboardMarkup(inline_keyboard=kb) # --- COMMANDS --- @dp.message(Command("start")) async def cmd_start(message: types.Message): await message.answer("🎧 **Music Bot Ready!**\nKirim format: `/play `", 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.") status_msg = await message.answer("🔍 *Mencari daftar lagu...*", parse_mode="Markdown") # Ambil 30 hasil sekaligus results = await search_youtube_list(query, max_results=30) if not results: return await status_msg.edit_text("❌ Lagu tidak ditemukan.") # Simpan ke memori sementara untuk paging user_searches[message.chat.id] = { 'query': query, 'results': results } # Tampilkan halaman pertama (index 0) kb = get_search_keyboard(message.chat.id, page=0) await status_msg.edit_text( text=f"🔎 Hasil pencarian: **{query}**\n_Pilih lagu untuk diunduh:_", reply_markup=kb, parse_mode="Markdown" ) # --- HANDLER TOMBOL PAGING (NEXT/PREV) --- @dp.callback_query(F.data.startswith("page_")) async def handle_page_click(callback: CallbackQuery): chat_id = callback.message.chat.id if chat_id not in user_searches: return await callback.answer("Pencarian kadaluarsa. Ketik /play lagi.", show_alert=True) page = int(callback.data.split("_")[1]) kb = get_search_keyboard(chat_id, page) if kb: query = user_searches[chat_id]['query'] await callback.message.edit_text( text=f"🔎 Hasil pencarian: **{query}**\n_Halaman {page+1}_", reply_markup=kb, parse_mode="Markdown" ) await callback.answer() # --- HANDLER DOWNLOAD LAGU --- @dp.callback_query(F.data.startswith("play_")) async def handle_suggestion_click(callback: CallbackQuery): video_id = callback.data.split("_", 1)[1] # Opsional: Jika tidak mau layarnya penuh tombol setelah diklik, hapus tombolnya await callback.message.edit_reply_markup(reply_markup=None) status_msg = await bot.send_message(callback.message.chat.id, "⏳ Mengunduh lagu pilihanmu...") query_url = f"https://www.youtube.com/watch?v={video_id}" await music_queue.put((query_url, callback.message.chat.id, status_msg.message_id)) await callback.answer("Siapp! Diproses...") # --- CORE LOGIC (DOWNLOAD & S3 CACHE) --- async def process_music_request(query: str, chat_id: int, status_msg_id: int): local_file = None try: video_id = extract_video_id(query) cached_data = None if video_id: cached_data = await db.get_cache(video_id) if cached_data: title = cached_data.get('title', 'Lagu') s3_object_name = cached_data.get('s3_object_key', f"{video_id}.m4a") ext = s3_object_name.split('.')[-1] local_file = f"downloads/cache_{video_id}.{ext}" await bot.edit_message_text("⚡ Mengambil dari Cache...", chat_id, status_msg_id) await download_audio(s3_object_name, local_file) if not cached_data: await bot.edit_message_text("🔍 Mengunduh dari YouTube...", chat_id, status_msg_id) yt_result = await process_youtube_request(query) 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"] s3_object_name = f"{video_id}.{yt_result['ext']}" await bot.edit_message_text("☁️ Menyimpan ke Server...", chat_id, status_msg_id) if local_file and os.path.exists(local_file): await upload_audio(local_file, s3_object_name) await db.save_cache(video_id, title, s3_object_name) await bot.edit_message_text("📤 Mengirim ke Telegram...", chat_id, status_msg_id) if local_file and os.path.exists(local_file): audio = FSInputFile(local_file) await bot.send_audio(chat_id=chat_id, audio=audio, title=title, performer="Music Bot", request_timeout=300) if status_msg_id: await bot.delete_message(chat_id, status_msg_id) # --- TETAP TAMPILKAN 5 SUGGESTION SETELAH LAGU SELESAI DIPUTAR --- recs = await get_recommendations(video_id) if recs: kb = [] for rec in recs[:5]: short_title = rec['title'][:55] + "..." if len(rec['title']) > 55 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, text=f"💡 **Rekomendasi dari:**\n_{title}_", reply_markup=reply_markup, parse_mode="Markdown") except Exception as e: logger.error(f"Error: {e}") finally: if local_file and os.path.exists(local_file): os.remove(local_file) 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']) 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 V3 (Search & Paging) Berjalan!") await bot.delete_webhook(drop_pending_updates=True) await dp.start_polling(bot) if __name__ == "__main__": asyncio.run(main())