#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ ═══════════════════════════════════════════════════════════════ Spotify → Telegram Bot · v3.0 FULL يرسل رابط Spotify → يرجّع MP3 + تفاصيل + غلاف بحث متعدد المصادر · yt-dlp بأوضاع متعددة · SoundCloud احتياط ═══════════════════════════════════════════════════════════════ """ import asyncio import io import os import re import time import shutil import subprocess import logging from typing import Optional from html import escape import requests from telegram import Update, InputFile, BotCommand from telegram.constants import ParseMode, ChatAction from telegram.ext import ( Application, CommandHandler, MessageHandler, ContextTypes, filters as tg_filters ) # ═══════════════════════════════════════════════════════════════ # CONFIG # ═══════════════════════════════════════════════════════════════ BOT_TOKEN = "8977155307:AAEpTfSP_IapYP-hn8Gg-HnclvxWD_Cpghw" TMP_DIR = os.path.join(os.path.expanduser("~"), "spotify_dl") os.makedirs(TMP_DIR, exist_ok=True) HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "en-US,en;q=0.9", } logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s" ) log = logging.getLogger("spotify-bot") SPOTIFY_RE = re.compile( r"https?://open\.spotify\.com/(?:intl-[a-z]{2}/)?(track|album|playlist|artist)/([A-Za-z0-9]+)" ) # ═══════════════════════════════════════════════════════════════ # HELPERS # ═══════════════════════════════════════════════════════════════ def esc(s): return escape(str(s)) if s is not None else "" def safe_name(s: str, max_len: int = 80) -> str: s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", str(s)) s = re.sub(r"\s+", " ", s).strip(" .") return s[:max_len] or "track" def cleanup_old(out_dir: str, max_age: int = 3600): try: now = time.time() for f in os.listdir(out_dir): p = os.path.join(out_dir, f) if os.path.isfile(p) and now - os.path.getmtime(p) > max_age: try: os.remove(p) except Exception: pass except Exception: pass def ffmpeg_available() -> bool: return shutil.which("ffmpeg") is not None def ytdlp_available() -> bool: return shutil.which("yt-dlp") is not None # ═══════════════════════════════════════════════════════════════ # SPOTIFY METADATA (oEmbed) # ═══════════════════════════════════════════════════════════════ def spotify_oembed(url: str) -> dict: r = requests.get( "https://open.spotify.com/oembed", params={"url": url}, headers=HEADERS, timeout=20 ) r.raise_for_status() return r.json() # ═══════════════════════════════════════════════════════════════ # بحث متعدد المصادر # ═══════════════════════════════════════════════════════════════ def yt_search_direct(query: str) -> Optional[str]: """YouTube scraping مباشر — video filter""" try: r = requests.get( "https://www.youtube.com/results", params={"search_query": query, "sp": "EgIQAQ%3D%3D"}, headers=HEADERS, timeout=20 ) vids = re.findall(r'"videoId":"([^"]{11})"', r.text) if vids: return f"https://www.youtube.com/watch?v={vids[0]}" except Exception as e: log.warning(f"yt direct: {e}") return None def yt_search_music(query: str) -> Optional[str]: """YouTube Music scraping""" try: r = requests.get( "https://music.youtube.com/search", params={"q": query}, headers=HEADERS, timeout=20 ) vids = re.findall(r'"videoId":"([^"]{11})"', r.text) if vids: return f"https://www.youtube.com/watch?v={vids[0]}" except Exception as e: log.warning(f"yt music: {e}") return None def yt_search_invidious(query: str) -> Optional[str]: """Invidious instances متعددة""" instances = [ "https://yewtu.be", "https://inv.nadeko.net", "https://invidious.nerdvpn.de", "https://invidious.privacyredirect.com", "https://iv.melmac.space", "https://invidious.f5.si", "https://invidious.reallyaweso.me", ] for base in instances: try: r = requests.get( f"{base}/api/v1/search", params={"q": query, "type": "video"}, headers=HEADERS, timeout=12 ) data = r.json() if isinstance(data, list) and data: vid = data[0].get("videoId") if vid: return f"https://www.youtube.com/watch?v={vid}" except Exception: continue return None def soundcloud_search(query: str) -> Optional[str]: """SoundCloud بحث مباشر""" try: r = requests.get( "https://soundcloud.com/search/sounds", params={"q": query}, headers=HEADERS, timeout=20 ) m = re.findall(r'"permalink_url":"(https://soundcloud\.com/[^"]+)"', r.text) if m: return m[0] except Exception: pass return None def youtube_search(query: str) -> Optional[str]: """يجرب كل المصادر بالترتيب حتى ينجح""" for fn in (yt_search_direct, yt_search_music, yt_search_invidious, soundcloud_search): try: url = fn(query) if url: log.info(f"Found via {fn.__name__}: {url}") return url except Exception as e: log.warning(f"{fn.__name__}: {e}") return None # ═══════════════════════════════════════════════════════════════ # yt-dlp متعدد الأوضاع # ═══════════════════════════════════════════════════════════════ def _run_ytdlp(url: str, out_dir: str, extra_args: list) -> Optional[str]: try: out_tpl = os.path.join(out_dir, "dl_%(id)s.%(ext)s") base = [ "yt-dlp", "--no-playlist", "--no-warnings", "--quiet", "--max-filesize", "48M", "--no-check-certificate", "-o", out_tpl ] cmd = base + extra_args + [url] r = subprocess.run(cmd, capture_output=True, timeout=280, text=True) if r.returncode != 0: log.warning(f"yt-dlp: {r.stderr[:200]}") return None files = [ os.path.join(out_dir, f) for f in os.listdir(out_dir) if f.startswith("dl_") and f.endswith((".mp3", ".m4a", ".webm", ".opus", ".ogg", ".aac")) ] if not files: return None newest = max(files, key=os.path.getmtime) # تحويل إلى mp3 if not newest.endswith(".mp3") and ffmpeg_available(): mp3_out = newest.rsplit(".", 1)[0] + ".mp3" try: conv = subprocess.run( ["ffmpeg", "-y", "-i", newest, "-vn", "-acodec", "libmp3lame", "-q:a", "2", mp3_out], capture_output=True, timeout=120, text=True ) if conv.returncode == 0 and os.path.exists(mp3_out): try: os.remove(newest) except Exception: pass return mp3_out except Exception: pass return newest except Exception as e: log.warning(f"_run_ytdlp: {e}") return None def ytdlp_download(url: str, out_dir: str) -> Optional[str]: """يجرب 7 أوضاع حتى ينجح""" if not ytdlp_available(): return None cleanup_old(out_dir) attempts = [ ["-x", "--audio-format", "mp3", "--audio-quality", "0"], ["-f", "bestaudio[ext=m4a]/bestaudio/best"], ["-x", "--audio-format", "mp3", "--extractor-args", "youtube:player_client=android"], ["-x", "--audio-format", "mp3", "--extractor-args", "youtube:player_client=ios"], ["-x", "--audio-format", "mp3", "--extractor-args", "youtube:player_client=web_safari"], ["-x", "--audio-format", "mp3", "--extractor-args", "youtube:player_client=tv_embedded"], ["-x", "--audio-format", "mp3", "--user-agent", HEADERS["User-Agent"]], ] for i, args in enumerate(attempts, 1): log.info(f"yt-dlp attempt {i}/{len(attempts)}") result = _run_ytdlp(url, out_dir, args) if result and os.path.exists(result): log.info(f"✅ Success with attempt {i}") return result return None # ═══════════════════════════════════════════════════════════════ # COMMANDS # ═══════════════════════════════════════════════════════════════ async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text( "🎵 Spotify → MP3 Downloader\n\n" "أرسل رابط Spotify وأنا أرجّعلك الأغنية:\n" "• 🎵 track — أغنية\n" "• 💿 album — ألبوم\n" "• 📂 playlist — قائمة\n\n" "مثال:\n" "https://open.spotify.com/track/xxxxx", parse_mode=ParseMode.HTML ) async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE): await cmd_start(update, context) async def cmd_id(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text( f"🆔 {update.effective_user.id}", parse_mode=ParseMode.HTML ) async def cmd_status(update: Update, context: ContextTypes.DEFAULT_TYPE): ytdlp = "✅" if ytdlp_available() else "❌" ffmpeg = "✅" if ffmpeg_available() else "❌" await update.message.reply_text( f"📊 حالة الأدوات\n\n" f"yt-dlp: {ytdlp}\n" f"ffmpeg: {ffmpeg}", parse_mode=ParseMode.HTML ) # ═══════════════════════════════════════════════════════════════ # TRACK FLOW # ═══════════════════════════════════════════════════════════════ async def send_single(update: Update, context: ContextTypes.DEFAULT_TYPE, spotify_url: str): msg = update.message m = await msg.reply_text("🔎 جاري تحليل الرابط...") # 1) oembed try: info = spotify_oembed(spotify_url) except Exception as e: await m.edit_text( f"❌ فشل قراءة الرابط\n{esc(str(e)[:150])}", parse_mode=ParseMode.HTML ) return title = (info.get("title") or "").strip() artist = (info.get("author_name") or "").strip() cover = info.get("thumbnail_url") or "" if not title: await m.edit_text("❌ لم أستطع قراءة اسم الأغنية") return query = f"{artist} {title}".strip() if artist else title # 2) عرض التقدم await m.edit_text( f"🎵 {esc(title)}\n" + (f"👤 {esc(artist)}\n" if artist else "") + "\n🔎 البحث على YouTube / SoundCloud...", parse_mode=ParseMode.HTML ) # 3) بحث متعدد yt_url = youtube_search(query) if not yt_url: # محاولة أخيرة: scsearch (SoundCloud search داخل yt-dlp) yt_url = f"scsearch1:{query}" await m.edit_text("📥 جاري التحميل...") # 4) yt-dlp loop = asyncio.get_event_loop() path = await loop.run_in_executor(None, ytdlp_download, yt_url, TMP_DIR) if not path or not os.path.exists(path): await m.edit_text( "❌ فشل التحميل\n\n" "تأكد من:\n" "• pip install --upgrade yt-dlp\n" "• pkg install ffmpeg\n\n" "أو الأغنية محذوفة من كل المصادر.", parse_mode=ParseMode.HTML ) return size = os.path.getsize(path) if size > 50 * 1024 * 1024: await m.edit_text(f"⚠️ الملف كبير ({size // 1024 // 1024}MB)") try: os.remove(path) except Exception: pass return caption = ( f"🎵 {esc(title)}\n" + (f"👤 {esc(artist)}\n" if artist else "") + f"🔗 Spotify" ) # 5) أرسل try: with open(path, "rb") as f: await msg.reply_audio( audio=InputFile(f, filename=safe_name(f"{artist} - {title}") + ".mp3"), title=title, performer=artist or "Unknown", caption=caption, parse_mode=ParseMode.HTML ) try: os.remove(path) except Exception: pass # 6) الغلاف if cover: try: img = requests.get(cover, headers=HEADERS, timeout=15).content ib = io.BytesIO(img) ib.name = "cover.jpg" await msg.reply_photo( ib, caption=f"💿 {esc(title)}", parse_mode=ParseMode.HTML ) except Exception: pass try: await m.delete() except Exception: pass except Exception as e: log.exception("send audio") await m.edit_text( f"❌ فشل الإرسال: {esc(str(e)[:180])}", parse_mode=ParseMode.HTML ) # ═══════════════════════════════════════════════════════════════ # TEXT HANDLER # ═══════════════════════════════════════════════════════════════ async def on_text(update: Update, context: ContextTypes.DEFAULT_TYPE): if not update.message or not update.message.text: return text = update.message.text.strip() m = SPOTIFY_RE.search(text) if not m: await update.message.reply_text("❌ أرسل رابط Spotify صالح") return kind, sid = m.group(1), m.group(2) url = f"https://open.spotify.com/{kind}/{sid}" await send_single(update, context, url) # ═══════════════════════════════════════════════════════════════ # ERROR HANDLER # ═══════════════════════════════════════════════════════════════ async def on_error(update, context): log.exception("error", exc_info=context.error) async def post_init(app: Application): try: await app.bot.set_my_commands([ BotCommand("start", "بدء"), BotCommand("help", "مساعدة"), BotCommand("status", "حالة الأدوات"), BotCommand("id", "ايدي حسابك"), ]) except Exception: pass # ═══════════════════════════════════════════════════════════════ # MAIN # ═══════════════════════════════════════════════════════════════ def main(): if not BOT_TOKEN or "PUT" in BOT_TOKEN: print("❌ ضع التوكن في BOT_TOKEN") return print("═" * 55) print("🎵 Spotify → Telegram Bot · v3.0") print("═" * 55) if not ytdlp_available(): print("⚠️ yt-dlp غير مثبت → pip install --upgrade yt-dlp") if not ffmpeg_available(): print("⚠️ ffmpeg غير مثبت → pkg install ffmpeg") print("═" * 55) app = ( Application.builder() .token(BOT_TOKEN) .post_init(post_init) .build() ) app.add_handler(CommandHandler("start", cmd_start)) app.add_handler(CommandHandler("help", cmd_help)) app.add_handler(CommandHandler("status", cmd_status)) app.add_handler(CommandHandler("id", cmd_id)) app.add_handler(MessageHandler(tg_filters.TEXT & ~tg_filters.COMMAND, on_text)) app.add_error_handler(on_error) print("✅ البوت يعمل. Ctrl+C للإيقاف.") print("═" * 55) app.run_polling(allowed_updates=Update.ALL_TYPES, drop_pending_updates=True) if __name__ == "__main__": try: main() except KeyboardInterrupt: print("\n⛔ تم الإيقاف")