├── .python-version ├── Procfile ├── .github ├── CODEOWNERS ├── README.md └── workflows │ └── dependency-update.yml ├── .dockerignore ├── .gitignore ├── sample.env ├── src ├── __main__.py ├── utils │ ├── __init__.py │ ├── _cache.py │ ├── _dataclass.py │ ├── _filters.py │ ├── _db.py │ ├── _api.py │ └── _downloader.py ├── config.py ├── modules │ ├── callback.py │ ├── song.py │ ├── _media_utils.py │ ├── yt_dlp.py │ ├── start.py │ ├── _fsub.py │ ├── _utils.py │ ├── snap.py │ ├── owner.py │ └── inline.py └── __init__.py ├── Dockerfile ├── pyproject.toml └── LICENSE /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: start 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @AshokShau 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | ** 2 | !*.py 3 | !src 4 | !pyproject.toml 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | .env 3 | __pycache__/ 4 | database/ 5 | sptubebot.egg-info/ 6 | cookies.txt 7 | -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | API_ID= 2 | API_HASH= 3 | TOKEN= 4 | API_KEY= 5 | LOGGER_ID=-1002434755494 6 | FSUB_ID=-1001818343794 7 | YT_COOKIES= 8 | -------------------------------------------------------------------------------- /src/__main__.py: -------------------------------------------------------------------------------- 1 | from src import client 2 | 3 | def main() -> None: 4 | client.run() 5 | 6 | if __name__ == "__main__": 7 | main() 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-slim 2 | 3 | WORKDIR /app 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y --no-install-recommends \ 7 | git \ 8 | ffmpeg \ 9 | && apt-get autoremove -y \ 10 | && rm -rf /var/lib/apt/lists/* 11 | 12 | RUN pip install --no-cache-dir uv 13 | 14 | COPY . . 15 | 16 | RUN uv pip install -e . --system 17 | 18 | CMD ["start"] 19 | -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from ._api import ApiData, HttpClient 2 | from ._cache import shortener 3 | from ._downloader import Download, download_playlist_zip 4 | from ._filters import Filter 5 | from ._dataclass import APIResponse 6 | from ._db import db 7 | 8 | __all__ = [ 9 | "ApiData", 10 | "Download", 11 | "Filter", 12 | "download_playlist_zip", 13 | "shortener", 14 | "APIResponse", 15 | "HttpClient", 16 | "db" 17 | ] 18 | -------------------------------------------------------------------------------- /src/utils/_cache.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from typing import Dict, Optional 3 | 4 | 5 | class URLShortener: 6 | def __init__(self) -> None: 7 | self.url_map: Dict[str, str] = {} 8 | self._token_len: int = 10 9 | 10 | def encode_url(self, url: str) -> str: 11 | token = self._generate_short_token(url) 12 | self.url_map[token] = url 13 | return token 14 | 15 | def decode_url(self, token: str) -> Optional[str]: 16 | return self.url_map.get(token) 17 | 18 | def _generate_short_token(self, url: str) -> str: 19 | hash_obj = hashlib.sha256(url.encode()) 20 | return hash_obj.hexdigest()[:self._token_len] 21 | 22 | 23 | shortener = URLShortener() 24 | -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | from decouple import config 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | 6 | API_ID: Optional[int] = config("API_ID", default=6, cast=int) 7 | API_HASH: Optional[str] = config("API_HASH", default="", cast=str) 8 | TOKEN: Optional[str] = config("TOKEN", default="") 9 | API_KEY = config("API_KEY") 10 | API_URL = config("API_URL", default="https://tgmusic.fallenapi.fun", cast=str) 11 | DOWNLOAD_PATH = Path(config("DOWNLOAD_PATH", "database/music")) 12 | MONGO_URI: Optional[str] = config("MONGO_URI", default="") 13 | LOGGER_ID = config("LOGGER_ID", default=-1002434755494, cast=int) 14 | FSUB_ID = config("FSUB_ID", default=0, cast=int) 15 | YT_COOKIES: Optional[str] = config("YT_COOKIES", default="") 16 | OWNER_ID = config("OWNER_ID", default=5938660179, cast=int) 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "sptubebot" 3 | version = "0.1.0" 4 | description = "A high-performance Telegram bot for downloading songs from Spotify, SoundCloud, Apple Music and YouTube in premium quality." 5 | requires-python = ">=3.13" 6 | dependencies = [ 7 | "httpx~=0.28.1", 8 | "meval~=2.5", 9 | "mutagen>=1.47.0", 10 | "orjson~=3.11.5", 11 | "pycryptodome~=3.23.0", 12 | "pydantic~=2.12.5", 13 | "pymongo~=4.15.5", 14 | "pytdbot==0.9.9", 15 | "tdjson==1.8.58.post2", 16 | "ujson~=5.11.0", 17 | "yt-dlp~=2025.12.8", 18 | "python-decouple~=3.8", 19 | ] 20 | 21 | [project.license] 22 | file = "LICENSE" 23 | 24 | [project.scripts] 25 | start = "src.__main__:main" 26 | 27 | [tool.uv] 28 | package = true 29 | 30 | [tool.setuptools] 31 | packages = [ 32 | "src", 33 | ] 34 | -------------------------------------------------------------------------------- /src/utils/_dataclass.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | from typing import List, Optional, Union 3 | 4 | 5 | class TrackInfo(BaseModel): 6 | cdnurl: str 7 | key: str 8 | name: str 9 | artist: str 10 | tc: str 11 | cover: str 12 | lyrics: str 13 | album: str 14 | year: int 15 | duration: int 16 | platform: str 17 | 18 | class MusicTrack(BaseModel): 19 | name: str 20 | artist: str 21 | id: str 22 | url: str 23 | year: str 24 | cover: str 25 | duration: int 26 | platform: str 27 | 28 | class PlatformTracks(BaseModel): 29 | results: List[MusicTrack] 30 | 31 | class APIVideo(BaseModel): 32 | video: Optional[str] = None 33 | thumbnail: Optional[str] = None 34 | 35 | class APIResponse(BaseModel): 36 | video: List[APIVideo] = [] 37 | image: Union[List[str], None] = None 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ashok 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/modules/callback.py: -------------------------------------------------------------------------------- 1 | from pytdbot import Client, types 2 | 3 | from src.utils import ApiData, shortener 4 | from ._media_utils import process_track_media, get_reply_markup 5 | from ._utils import handle_help_callback, StartMessage 6 | from .start import get_main_menu_keyboard 7 | 8 | 9 | @Client.on_updateNewCallbackQuery() 10 | async def callback_query(c: Client, message: types.UpdateNewCallbackQuery): 11 | data = message.payload.data.decode() 12 | user_id = message.sender_user_id 13 | # Help menu 14 | if data.startswith("help_"): 15 | await handle_help_callback(c, message) 16 | return 17 | 18 | # Back to main menu 19 | if data == "back_menu": 20 | await message.answer("⏳ Returning to main menu…") 21 | bot_username = c.me.usernames.editable_username 22 | bot_name = c.me.first_name 23 | await message.edit_message_text( 24 | text=StartMessage.format(bot_name=bot_name, bot_username=bot_username), 25 | disable_web_page_preview=True, 26 | reply_markup=get_main_menu_keyboard(bot_username) 27 | ) 28 | return 29 | 30 | # Only handle spot_ callbacks 31 | if not data.startswith("spot_"): 32 | await message.answer("Unexpected callback data", show_alert=True) 33 | await c.deleteMessages(message.chat_id, [message.message_id]) 34 | return 35 | 36 | split1, split2 = data.find("_"), data.rfind("_") 37 | if split1 == -1 or split2 == -1 or split1 == split2: 38 | await c.deleteMessages(message.chat_id, [message.message_id]) 39 | return 40 | 41 | id_enc, uid = data[split1 + 1: split2], data[split2 + 1:] 42 | if uid not in ("0", str(user_id)): 43 | await message.answer("🚫 This button wasn't meant for you.", show_alert=True) 44 | return 45 | 46 | await message.answer("⏳ Processing your track, please wait...", show_alert=True) 47 | url = shortener.decode_url(id_enc) 48 | if not url: 49 | await c.deleteMessages(message.chat_id, [message.message_id]) 50 | return 51 | 52 | # Get track info 53 | api = ApiData(url) 54 | track = await api.get_track() 55 | if isinstance(track, types.Error): 56 | await message.edit_message_text(f"❌ Failed to fetch track: {track.message or 'Unknown error'}") 57 | return 58 | 59 | msg = await message.edit_message_text("🔄 Downloading the song...") 60 | if isinstance(msg, types.Error): 61 | c.logger.warning(f"❌ Failed to edit message: {msg.message}") 62 | return 63 | 64 | # Process the track media 65 | audio, cover, status_text = await process_track_media( 66 | c, track, chat_id=message.chat_id, message_id=message.message_id 67 | ) 68 | 69 | if not audio: 70 | await message.edit_message_text(status_text) 71 | return 72 | 73 | # Get reply markup 74 | reply_markup = get_reply_markup(track.name, track.artist) 75 | parse = await c.parseTextEntities(status_text, types.TextParseModeHTML()) 76 | 77 | # Send the audio 78 | reply = await c.editMessageMedia( 79 | chat_id=message.chat_id, 80 | message_id=message.message_id, 81 | input_message_content=types.InputMessageAudio( 82 | audio=audio, 83 | album_cover_thumbnail=types.InputThumbnail(types.InputFileLocal(cover)) if cover else None, 84 | title=track.name, 85 | performer=track.artist, 86 | duration=track.duration, 87 | caption=parse, 88 | ), 89 | reply_markup=reply_markup, 90 | ) 91 | 92 | if isinstance(reply, types.Error): 93 | c.logger.error(f"❌ Failed to send audio file: {reply.message}") 94 | await msg.edit_text("❌ Failed to send the song. Please try again later.") 95 | -------------------------------------------------------------------------------- /src/modules/song.py: -------------------------------------------------------------------------------- 1 | from pytdbot import Client, types 2 | from pytdbot.exception import StopHandlers 3 | 4 | from src.utils import ApiData, shortener, Filter, download_playlist_zip 5 | from ._fsub import fsub 6 | 7 | 8 | async def process_spotify_query(message: types.Message, query: str): 9 | response = await message.reply_text("⏳ Searching for tracks...") 10 | if isinstance(response, types.Error): 11 | await message.reply_text(f"Error: {response.message}") 12 | return 13 | 14 | api = ApiData(query) 15 | song_data = await api.get_info() if api.is_valid() else await api.search(limit="5") 16 | if isinstance(song_data, types.Error): 17 | await response.edit_text(f"❌ Error: {song_data.message}") 18 | return 19 | 20 | if not song_data or not song_data.results: 21 | await response.edit_text("❌ No results found.") 22 | return 23 | 24 | keyboard = [ 25 | [types.InlineKeyboardButton( 26 | text=f"{track.name} - {track.artist}", 27 | type=types.InlineKeyboardButtonTypeCallback( 28 | f"spot_{shortener.encode_url(track.url)}_0".encode() 29 | ) 30 | )] 31 | for track in song_data.results 32 | ] 33 | 34 | await response.edit_text( 35 | f"Search results for: {query}\n\nPlease tap on the song you want to download.", 36 | parse_mode="html", 37 | disable_web_page_preview=True, 38 | reply_markup=types.ReplyMarkupInlineKeyboard(keyboard), 39 | ) 40 | 41 | 42 | @Client.on_message(filters=Filter.command(["spot", "spotify", "song"])) 43 | @fsub 44 | async def spotify_cmd(_: Client, message: types.Message): 45 | parts = message.text.split(" ", 1) 46 | if len(parts) < 2: 47 | await message.reply_text("Please provide a search query.") 48 | return 49 | 50 | query = parts[1] 51 | await process_spotify_query(message, query) 52 | raise StopHandlers 53 | 54 | 55 | @Client.on_message(filters=Filter.sp_tube()) 56 | @fsub 57 | async def spotify_autodetect(_: Client, message: types.Message): 58 | await process_spotify_query(message, message.text) 59 | raise StopHandlers 60 | 61 | 62 | @Client.on_message(filters=Filter.command(["dl_zip", "playlist"])) 63 | @fsub 64 | async def dl_playlist(c: Client, message: types.Message): 65 | parts = message.text.strip().split(" ", 1) 66 | 67 | if len(parts) < 2 or not parts[1].strip(): 68 | await message.reply_text("❗ Please provide a playlist URL or search query.\n\nExample: `/dl_zip artist or url`", parse_mode="markdown") 69 | return 70 | 71 | query = parts[1].strip() 72 | api = ApiData(query) 73 | result = await api.get_info() 74 | if isinstance(result, types.Error): 75 | await message.reply_text(f"❌ Failed to fetch playlist.\n{result.message}", parse_mode="html") 76 | return 77 | 78 | if not result.results: 79 | await message.reply_text("⚠️ No tracks found for the given input.") 80 | return 81 | 82 | first_song = result.results[0] 83 | if first_song.platform == "youtube": 84 | await message.reply_text("You can't download YouTube playlists.") 85 | return 86 | 87 | if len(result.results) > 30: 88 | result.results = result.results[:30] 89 | 90 | reply = await message.reply_text(f"⏳ Downloading {len(result.results)} tracks and creating ZIP…") 91 | zip_path = await download_playlist_zip(result) 92 | if not zip_path: 93 | await message.reply_text("❌ Failed to download any tracks. Please try again.") 94 | return 95 | 96 | ok = await c.editMessageMedia( 97 | chat_id=reply.chat_id, 98 | message_id=reply.id, 99 | input_message_content=types.InputMessageDocument(types.InputFileLocal(zip_path)), 100 | ) 101 | if isinstance(ok, types.Error): 102 | await message.reply_text(f"❌ Error: {ok.message}") 103 | return 104 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import shutil 3 | from datetime import datetime 4 | 5 | from pytdbot import Client, types 6 | 7 | from src import config 8 | from src.utils import HttpClient, db 9 | 10 | logging.basicConfig( 11 | level=logging.INFO, 12 | format="[%(asctime)s - %(levelname)s] - %(name)s - %(filename)s:%(lineno)d - %(message)s", 13 | datefmt="%d-%b-%y %H:%M:%S", 14 | handlers=[logging.StreamHandler()], 15 | ) 16 | 17 | logging.getLogger("httpx").setLevel(logging.WARNING) 18 | logging.getLogger("pymongo").setLevel(logging.WARNING) 19 | 20 | LOGGER = logging.getLogger("Bot") 21 | 22 | StartTime = datetime.now() 23 | 24 | 25 | class Telegram(Client): 26 | def __init__(self) -> None: 27 | self._check_config() 28 | super().__init__( 29 | token=config.TOKEN, 30 | api_id=config.API_ID, 31 | api_hash=config.API_HASH, 32 | default_parse_mode="html", 33 | td_verbosity=2, 34 | td_log=types.LogStreamEmpty(), 35 | plugins=types.plugins.Plugins(folder="src/modules"), 36 | files_directory="", 37 | database_encryption_key="", 38 | options={"ignore_background_updates": True}, 39 | ) 40 | 41 | self._http_client = HttpClient() 42 | 43 | async def start(self) -> None: 44 | await self._http_client.get_client() 45 | await self.loop.create_task(self._save_cookies()) 46 | await super().start() 47 | self.logger.info(f"Bot started in {datetime.now() - StartTime} seconds.") 48 | await db.connect() 49 | 50 | async def stop(self) -> None: 51 | await self._http_client.close_client() 52 | await db.close() 53 | await super().stop() 54 | 55 | @staticmethod 56 | def _check_config() -> None: 57 | # Check if FFmpeg is installed 58 | if not shutil.which('ffmpeg'): 59 | raise RuntimeError( 60 | "FFmpeg is not installed or not in system PATH. " 61 | "Please install FFmpeg to run this bot." 62 | ) 63 | 64 | required_keys = [ 65 | "TOKEN", 66 | "API_ID", 67 | "API_HASH", 68 | "API_KEY", 69 | "API_URL", 70 | "MONGO_URI", 71 | "LOGGER_ID", 72 | ] 73 | 74 | if missing := [ 75 | key for key in required_keys if not getattr(config, key, None) 76 | ]: 77 | raise RuntimeError( 78 | f"Missing required config values in .env: {', '.join(missing)}" 79 | ) 80 | 81 | 82 | async def _save_cookies(self) -> None: 83 | from pathlib import Path 84 | from urllib.parse import urlparse 85 | from typing import Optional 86 | 87 | async def _download_cookies(url: str) -> Optional[str]: 88 | try: 89 | parsed = urlparse(url) 90 | if parsed.netloc != 'batbin.me': 91 | LOGGER.error(f"Invalid domain in URL: {url}") 92 | return None 93 | 94 | paste_id = parsed.path.strip('/').split('/')[-1] 95 | if not paste_id: 96 | LOGGER.error(f"Could not extract paste ID from URL: {url}") 97 | return None 98 | 99 | raw_url = f"https://batbin.me/raw/{paste_id}" 100 | http_client = await self._http_client.get_client() 101 | resp = await http_client.get(raw_url) 102 | if resp.status_code != 200: 103 | LOGGER.error(f"Failed to download cookies from {url}: HTTP {resp.status_code}") 104 | return None 105 | return resp.text 106 | except Exception as exc: 107 | LOGGER.error(f"Error downloading cookies: {str(exc)}") 108 | return None 109 | 110 | db_dir = Path("database") 111 | db_dir.mkdir(exist_ok=True) 112 | if config.YT_COOKIES: 113 | if content := await _download_cookies(config.YT_COOKIES): 114 | filename = "yt_cookies.txt" 115 | try: 116 | (db_dir / filename).write_text(content) 117 | LOGGER.info(f"Successfully saved {filename}") 118 | except Exception as e: 119 | LOGGER.error(f"Failed to save cookies to file: {str(e)}") 120 | return None 121 | 122 | 123 | client: Telegram = Telegram() 124 | -------------------------------------------------------------------------------- /.github/README.md: -------------------------------------------------------------------------------- 1 | # 🎵 SpTubeBot - Telegram Music & Media Downloader Bot 2 | 3 | A powerful Telegram bot that lets you download music and media from various platforms in high quality. 4 | 5 |

6 | 7 | Stars 8 | 9 | 10 | Forks 11 | 12 | 13 | Release 14 | 15 | 16 | License 17 | 18 | 19 | API Key 20 | 21 |

22 | 23 | ## 🚀 Features 24 | 25 | - 🎧 Download music in 320kbps quality 26 | - 🔗 Supports multiple platforms: 27 | - Music: Spotify, YouTube, SoundCloud, Apple Music 28 | - Media: Instagram, Pinterest, Facebook, TikTok, Twitter, Threads 29 | - 📥 Works with tracks, albums, and playlists 30 | - 🤖 Telegram inline support and command mode 31 | - 💾 Built-in cache for faster responses 32 | - 🐳 Docker-ready for easy deployment 33 | - 🔐 Secure environment-based configuration 34 | 35 | ## 📋 Prerequisites 36 | 37 | Before you begin, make sure you have: 38 | 39 | 1. Python 3.10 or higher 40 | 2. A Telegram Bot Token from @BotFather 41 | 3. An API Key from @FallenApiBot 42 | 4. Required system dependencies: 43 | - ffmpeg (for audio processing) 44 | - tmux (for process management) 45 | 46 | ## 🛠️ Installation 47 | 48 | ### Option 1: Manual Installation 49 | 50 | ```bash 51 | # 1. Install system dependencies 52 | sudo apt-get install git python3-pip ffmpeg vorbis-tools tmux -y 53 | 54 | # 2. Install uv (Python package manager) 55 | pip3 install uv 56 | 57 | # 3. Clone the repository 58 | git clone https://github.com/AshokShau/SpTubeBot 59 | 60 | cd SpTubeBot 61 | 62 | # 4. Create virtual environment 63 | uv venv 64 | 65 | # 5. Activate virtual environment 66 | source .venv/bin/activate 67 | 68 | # 6. Install dependencies 69 | uv pip install -e . 70 | 71 | # 7. Copy and edit environment file 72 | cp sample.env .env 73 | nano .env 74 | 75 | # 8. Run the bot 76 | start 77 | ``` 78 | 79 | ### Option 2: Docker Deployment (Recommended) 80 | 81 | ```bash 82 | # 1. Build the Docker image 83 | docker build -t sp-tube-bot . 84 | 85 | # 2. Run the container (Make sure to create a .env file first) 86 | docker run -d --name songbot --env-file .env sp-tube-bot 87 | ``` 88 | 89 | ## 📝 Environment Variables 90 | 91 | Create a `.env` file with the following variables: 92 | 93 | ```env 94 | API_ID=your_api_id 95 | API_HASH=your_api_hash 96 | TOKEN=your_telegram_bot_token 97 | API_KEY=your_fallen_api_key # Get from @FallenApiBot 98 | API_URL=https://tgmusic.fallenapi.fun 99 | DOWNLOAD_PATH=database 100 | LOGGER_ID=-1002434755494 101 | ``` 102 | 103 | ## 🤖 Using the Bot 104 | 105 | 1. Start the bot using either manual or Docker deployment 106 | 2. Search for the bot in Telegram and start a chat 107 | 3. You can use the bot in two ways: 108 | - Inline mode: Type `@your_bot_name` in any chat and search for music 109 | - Command mode: Send commands directly to the bot 110 | 111 | Available commands: 112 | - `/start` - Start the bot 113 | - `/help` - Get help and command list 114 | - `/song` - Download a song 115 | - `/playlist` - Download a playlist 116 | - Just send a link to the bot and it will download the media 117 | 118 | 119 | ## 📝 License 120 | 121 | This project is licensed under the MIT License - see the [LICENSE](/LICENSE) file for details. 122 | 123 | ## 👥 Contributing 124 | 125 | 1. Fork the repository 126 | 2. Create your feature branch (`git checkout -b feature/AmazingFeature`) 127 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) 128 | 4. Push to the branch (`git push origin feature/AmazingFeature`) 129 | 5. Open a Pull Request 130 | 131 | ## 🙏 Support 132 | 133 | If you encounter any issues or have questions: 134 | 135 | 1. Check the [issues](https://github.com/AshokShau/SpTubeBot/issues) page 136 | 2. Create a new issue if your problem isn't listed 137 | 3. For general questions, you can also message @AshokShau on Telegram 138 | 139 | ## 📝 Note 140 | 141 | This bot is intended for personal use and educational purposes. Please respect copyright laws and terms of service when using this bot. 142 | -------------------------------------------------------------------------------- /src/modules/_media_utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Tuple, Optional, Union, TYPE_CHECKING 3 | from pytdbot import types, Client 4 | from src.utils import ApiData, Download, shortener, db 5 | 6 | if TYPE_CHECKING: 7 | from src.utils._dataclass import TrackInfo 8 | 9 | 10 | async def process_track_media( 11 | c: Client, 12 | track: 'TrackInfo' , 13 | chat_id: Optional[int] = None, 14 | message_id: Optional[int] = None, 15 | inline_message_id: Optional[str] = None 16 | ) -> Tuple[Optional[types.InputFile], Optional[str], str]: 17 | """ 18 | Process media for a track, handling both regular and inline messages. 19 | 20 | Args: 21 | c: The Telegram client instance 22 | track: The track to process 23 | chat_id: Chat ID for regular messages (None for inline) 24 | message_id: Message ID for regular messages (None for inline) 25 | inline_message_id: Inline message ID for inline messages (None for regular) 26 | 27 | Returns: 28 | Tuple containing (audio_file, cover_path, status_text) 29 | """ 30 | status_text = f"🎵 {track.name}\n👤 {track.artist} | 📀 {track.album}\n⏱️ {track.duration}s" 31 | parsed_status = await c.parseTextEntities(status_text, types.TextParseModeHTML()) 32 | # Update status message 33 | if inline_message_id: 34 | await c.editInlineMessageText( 35 | inline_message_id=inline_message_id, 36 | input_message_content=types.InputMessageText(parsed_status) 37 | ) 38 | elif chat_id and message_id: 39 | await c.editMessageText( 40 | chat_id=chat_id, 41 | message_id=message_id, 42 | input_message_content=types.InputMessageText(parsed_status) 43 | ) 44 | 45 | audio_file, cover = None, None 46 | audio: Optional[Union[types.InputFileLocal, types.InputFileRemote]] = None 47 | 48 | # Spotify shortcut if file already cached 49 | if track.platform.lower() == "spotify" and track.tc: 50 | if file_id := await db.get_song_file_id(track.tc): 51 | audio = types.InputFileRemote(file_id) 52 | 53 | # Download if not found in DB 54 | if not audio: 55 | dl = Download(track) 56 | result = await dl.process() 57 | if isinstance(result, types.Error): 58 | error_msg = f"❌ Download failed.\n{result.message}" 59 | return None, None, error_msg 60 | 61 | audio_file, cover = result 62 | if not audio_file: 63 | return None, None, "❌ Failed to download song.\nPlease report this to @FallenProjects." 64 | 65 | if track.platform.lower() == "spotify": 66 | file_id = await db.upload_song_and_get_file_id(audio_file, cover, track) 67 | if isinstance(file_id, types.Error): 68 | return None, None, file_id.message or "❌ Failed to send song to database." 69 | audio = types.InputFileRemote(file_id) 70 | 71 | elif re.match(r"https?://t\.me/([^/]+)/(\d+)", audio_file): 72 | info = await c.getMessageLinkInfo(audio_file) 73 | if isinstance(info, types.Error) or not info.message: 74 | return None, None, f"❌ Failed to resolve link: {audio_file}" 75 | 76 | public_msg = await c.getMessage(info.chat_id, info.message.id) 77 | if isinstance(public_msg, types.Error): 78 | return None, None, f"❌ Failed to fetch message: {public_msg.message}" 79 | 80 | if isinstance(public_msg.content, types.MessageAudio): 81 | audio = types.InputFileRemote(public_msg.content.audio.audio.remote.id) 82 | elif isinstance(public_msg.content, types.MessageDocument): 83 | audio = types.InputFileRemote(public_msg.content.document.document.remote.id) 84 | elif isinstance(public_msg.content, types.MessageVideo): 85 | audio = types.InputFileRemote(public_msg.content.video.video.remote.id) 86 | else: 87 | return None, None, f"❌ No audio file in t.me link: {audio_file}" 88 | else: 89 | audio = types.InputFileLocal(audio_file) 90 | 91 | return audio, cover, status_text 92 | 93 | 94 | def get_reply_markup(track_name: str, artist: str) -> types.ReplyMarkupInlineKeyboard: 95 | """Generate a reply markup with the track name and update button.""" 96 | return types.ReplyMarkupInlineKeyboard( 97 | [ 98 | [ 99 | types.InlineKeyboardButton( 100 | text=track_name, 101 | type=types.InlineKeyboardButtonTypeSwitchInline( 102 | query=artist, 103 | target_chat=types.TargetChatCurrent() 104 | ) 105 | ) 106 | ], 107 | [ 108 | types.InlineKeyboardButton( 109 | text="Update ", 110 | type=types.InlineKeyboardButtonTypeUrl("https://t.me/FallenProjects"), 111 | ) 112 | ] 113 | ] 114 | ) 115 | -------------------------------------------------------------------------------- /src/utils/_filters.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Union, Optional, Pattern, Set 3 | 4 | from pytdbot import filters, types 5 | from src.utils._api import ApiData 6 | 7 | 8 | class Filter: 9 | @staticmethod 10 | def _extract_text(event: Union[types.Message, types.UpdateNewMessage, types.UpdateNewCallbackQuery]) -> Optional[ 11 | str]: 12 | """Extract text content from different event types.""" 13 | if isinstance(event, types.Message) and isinstance(event.content, types.MessageText): 14 | return event.content.text.text 15 | if isinstance(event, types.UpdateNewMessage) and isinstance(event.message, types.MessageText): 16 | return event.message.text.text 17 | if isinstance(event, types.UpdateNewCallbackQuery) and event.payload: 18 | return event.payload.data.decode() 19 | return None 20 | 21 | @staticmethod 22 | def command(commands: Union[str, list[str]], prefixes: str = "/!") -> filters.Filter: 23 | """ 24 | Filter for commands. 25 | 26 | Args: 27 | commands: Single command or list of commands to match 28 | prefixes: String of allowed command prefixes (default: "/!") 29 | 30 | Returns: 31 | Filter that matches commands with optional bot username mention 32 | """ 33 | commands_set: Set[str] = {cmd.lower() for cmd in ([commands] if isinstance(commands, str) else commands)} 34 | pattern: Pattern = re.compile( 35 | rf"^[{re.escape(prefixes)}](\w+)(?:@(\w+))?", 36 | re.IGNORECASE 37 | ) 38 | 39 | async def filter_func(client, event) -> bool: 40 | text = Filter._extract_text(event) 41 | if not text: 42 | return False 43 | 44 | match = pattern.match(text.strip()) 45 | if not match: 46 | return False 47 | 48 | cmd, mentioned_bot = match.groups() 49 | if cmd.lower() not in commands_set: 50 | return False 51 | 52 | if mentioned_bot: 53 | bot_username = client.me.usernames.editable_username 54 | return bot_username and mentioned_bot.lower() == bot_username.lower() 55 | 56 | return True 57 | 58 | return filters.create(filter_func) 59 | 60 | @staticmethod 61 | def regex(pattern: str, flags: int = 0) -> filters.Filter: 62 | """ 63 | Filter for messages or callback queries matching a regex pattern. 64 | 65 | Args: 66 | pattern: Regex pattern to match 67 | flags: Regex flags (default: 0) 68 | 69 | Returns: 70 | Filter that matches text against the compiled regex pattern 71 | """ 72 | compiled: Pattern = re.compile(pattern, flags) 73 | 74 | async def filter_func(_, event) -> bool: 75 | text = Filter._extract_text(event) 76 | return bool(compiled.search(text)) if text else False 77 | 78 | return filters.create(filter_func) 79 | 80 | @staticmethod 81 | def save_snap() -> filters.Filter: 82 | """ 83 | Filter for Snapchat URLs that should be saved. 84 | 85 | Returns: 86 | Filter that matches valid Snapchat URLs and excludes commands 87 | """ 88 | command_pattern: Pattern = re.compile(r"^[!/]\w+(?:@\w+)?", re.IGNORECASE) 89 | 90 | async def filter_func(_, event) -> bool: 91 | text = Filter._extract_text(event) 92 | if not text or command_pattern.match(text.strip()): 93 | return False 94 | 95 | return ApiData(text).is_save_snap_url() 96 | 97 | return filters.create(filter_func) 98 | 99 | @staticmethod 100 | def sp_tube() -> filters.Filter: 101 | """ 102 | Filter for special tube URLs with additional checks. 103 | 104 | Returns: 105 | Filter that matches valid URLs and handles private/group chat logic 106 | """ 107 | command_pattern: Pattern = re.compile(r"^[!/]\w+(?:@\w+)?", re.IGNORECASE) 108 | 109 | async def filter_func(client, event) -> bool: 110 | text = Filter._extract_text(event) 111 | if not text or command_pattern.match(text.strip()): 112 | return False 113 | 114 | chat_id: Optional[int] = None 115 | if isinstance(event, types.Message): 116 | if event.via_bot_user_id == client.me.id: 117 | return False 118 | chat_id = event.chat_id 119 | elif isinstance(event, types.UpdateNewMessage): 120 | if event.message.via_bot_user_id == client.me.id: 121 | return False 122 | chat_id = getattr(event.message, "chat_id", None) 123 | 124 | if chat_id is None: 125 | return False 126 | 127 | if ApiData(text).is_valid(): 128 | return True 129 | 130 | return False if re.match("^https?://", text) else chat_id > 0 131 | 132 | return filters.create(filter_func) 133 | -------------------------------------------------------------------------------- /src/modules/yt_dlp.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import random 4 | import re 5 | 6 | import httpx 7 | from pytdbot import Client, types 8 | 9 | from src.config import DOWNLOAD_PATH 10 | from src.utils import Filter 11 | from ._fsub import fsub 12 | 13 | 14 | async def get_working_proxy(): 15 | urls = { 16 | "socks5": "https://raw.githubusercontent.com/olgavlncia/proxyhub/main/output/socks5.txt", 17 | "http": "https://raw.githubusercontent.com/olgavlncia/proxyhub/main/output/http.txt", 18 | } 19 | 20 | async with httpx.AsyncClient(timeout=5) as client: 21 | socks5_req, http_req = await asyncio.gather( 22 | client.get(urls["socks5"]), 23 | client.get(urls["http"]), 24 | ) 25 | 26 | socks5_list = [f"socks5://{p.strip()}" for p in socks5_req.text.strip().split("\n")[-50:] if p.strip()] 27 | http_list = [f"http://{p.strip()}" for p in http_req.text.strip().split("\n")[-50:] if p.strip()] 28 | candidates = socks5_list + http_list 29 | 30 | for proxy in random.sample(candidates, len(candidates)): 31 | try: 32 | async with httpx.AsyncClient(proxy=proxy, timeout=5) as pclient: 33 | r = await pclient.get("https://httpbin.org/ip") 34 | if r.status_code == 200: 35 | return proxy 36 | except Exception: 37 | continue 38 | return None 39 | 40 | 41 | @Client.on_message(filters=Filter.command(["yt", "youtube"])) 42 | @fsub 43 | async def youtube_cmd(c: Client, message: types.Message): 44 | parts = message.text.split(" ", 1) 45 | if len(parts) < 2: 46 | await message.reply_text("Please provide a search query.") 47 | return 48 | 49 | query = parts[1] 50 | if not re.match(r"^https?://", query): 51 | await message.reply_text("Please provide a valid URL.") 52 | return 53 | 54 | is_yt_url = "youtube.com" in query.lower() or "youtu.be" in query.lower() 55 | reply = await message.reply_text("🔍 Preparing download...") 56 | 57 | 58 | output_template = str(DOWNLOAD_PATH / "%(title).80s.%(ext)s") 59 | 60 | format_selector = "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio/best" 61 | ytdlp_params = [ 62 | "yt-dlp", 63 | "--no-warnings", 64 | "--no-playlist", 65 | "--quiet", 66 | "--geo-bypass", 67 | "--retries", "2", 68 | "--continue", 69 | "--no-part", 70 | "--restrict-filenames", 71 | "--concurrent-fragments", "3", 72 | "--socket-timeout", "10", 73 | "--throttled-rate", "100K", 74 | "--retry-sleep", "1", 75 | "--no-write-info-json", 76 | "--no-embed-metadata", 77 | "--no-embed-chapters", 78 | "--no-embed-subs", 79 | "--merge-output-format", "mp4", 80 | "--extractor-args", "youtube:player_js_version=actual", 81 | "-o", output_template, 82 | "-f", format_selector, 83 | "--print", "after_move:filepath", 84 | query 85 | ] 86 | 87 | if is_yt_url: 88 | cookie_file = "database/yt_cookies.txt" 89 | if os.path.exists(cookie_file): 90 | ytdlp_params += ["--cookies", cookie_file] 91 | else: 92 | await reply.edit_text("Selecting a working proxy...") 93 | proxy = await get_working_proxy() 94 | if not proxy: 95 | await reply.edit_text("❌ No working proxies found.") 96 | return 97 | if proxy: 98 | ytdlp_params += ["--proxy", proxy] 99 | 100 | proc = await asyncio.create_subprocess_exec( 101 | *ytdlp_params, 102 | stdout=asyncio.subprocess.PIPE, 103 | stderr=asyncio.subprocess.PIPE, 104 | ) 105 | 106 | try: 107 | stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300) 108 | except asyncio.TimeoutError: 109 | await reply.edit_text("⏳ Download timed out.") 110 | return 111 | 112 | if proc.returncode != 0: 113 | error_msg = stderr.decode().strip() 114 | if "is not a valid URL" in error_msg: 115 | await reply.edit_text("❌ Invalid URL provided. Please provide a valid video URL.") 116 | else: 117 | await reply.edit_text(f"❌ Error downloading:\n{error_msg}") 118 | return 119 | 120 | downloaded_path = stdout.decode().strip() 121 | if not downloaded_path: 122 | await reply.edit_text("❌ Could not find downloaded file.") 123 | return 124 | 125 | try: 126 | if not os.path.exists(downloaded_path): 127 | await reply.edit_text("❌ Downloaded file not found.") 128 | return 129 | 130 | done = await message.reply_video( 131 | video=types.InputFileLocal(downloaded_path), 132 | supports_streaming=True, 133 | caption="This video automatically deletes in 2 minutes so save or forward it now.", 134 | ) 135 | if isinstance(done, types.Error): 136 | await reply.edit_text(f"❌ Error: {done.message}") 137 | else: 138 | await reply.delete() 139 | async def delete_message(): 140 | await done.delete() 141 | c.loop.call_later(120, lambda: asyncio.create_task(delete_message())) 142 | finally: 143 | try: 144 | os.remove(downloaded_path) 145 | except Exception as e: 146 | c.logger.error(f"Error deleting file {downloaded_path}: {e}") 147 | -------------------------------------------------------------------------------- /src/modules/start.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime 3 | 4 | from pytdbot import Client, types 5 | 6 | from src import StartTime 7 | from src.utils import Filter, ApiData 8 | 9 | from ._fsub import fsub 10 | from ._utils import StartMessage 11 | 12 | 13 | def get_main_menu_keyboard(bot_username: str) -> types.ReplyMarkupInlineKeyboard: 14 | return types.ReplyMarkupInlineKeyboard([ 15 | [ 16 | types.InlineKeyboardButton( 17 | text="➕ Add to Group", 18 | type=types.InlineKeyboardButtonTypeUrl( 19 | url=f"https://t.me/{bot_username}?startgroup=true" 20 | ) 21 | ), 22 | types.InlineKeyboardButton( 23 | text="📂 GitHub", 24 | type=types.InlineKeyboardButtonTypeUrl( 25 | url="https://github.com/AshokShau/SpTubeBot" 26 | ) 27 | ) 28 | ], 29 | [ 30 | types.InlineKeyboardButton( 31 | text="Spotify", 32 | type=types.InlineKeyboardButtonTypeCallback("help_spotify".encode()) 33 | ), 34 | types.InlineKeyboardButton( 35 | text="YouTube", 36 | type=types.InlineKeyboardButtonTypeCallback("help_youtube".encode()) 37 | ) 38 | ], 39 | [ 40 | types.InlineKeyboardButton( 41 | text="SoundCloud", 42 | type=types.InlineKeyboardButtonTypeCallback("help_soundcloud".encode()) 43 | ), 44 | types.InlineKeyboardButton( 45 | text="Apple Music", 46 | type=types.InlineKeyboardButtonTypeCallback("help_apple".encode()) 47 | ) 48 | ], 49 | [ 50 | types.InlineKeyboardButton( 51 | text="Instagram", 52 | type=types.InlineKeyboardButtonTypeCallback("help_instagram".encode()) 53 | ), 54 | types.InlineKeyboardButton( 55 | text="Pinterest", 56 | type=types.InlineKeyboardButtonTypeCallback("help_pinterest".encode()) 57 | ) 58 | ], 59 | [ 60 | types.InlineKeyboardButton( 61 | text="Facebook", 62 | type=types.InlineKeyboardButtonTypeCallback("help_facebook".encode()) 63 | ), 64 | types.InlineKeyboardButton( 65 | text="Twitter", 66 | type=types.InlineKeyboardButtonTypeCallback("help_twitter".encode()) 67 | ) 68 | ], 69 | [ 70 | types.InlineKeyboardButton( 71 | text="TikTok", 72 | type=types.InlineKeyboardButtonTypeCallback("help_tiktok".encode()) 73 | ), 74 | types.InlineKeyboardButton( 75 | text="Threads", 76 | type=types.InlineKeyboardButtonTypeCallback("help_threads".encode()) 77 | ) 78 | ], 79 | [ 80 | types.InlineKeyboardButton( 81 | text="Reddit", 82 | type=types.InlineKeyboardButtonTypeCallback("help_reddit".encode()) 83 | ), 84 | types.InlineKeyboardButton( 85 | text="Twitch", 86 | type=types.InlineKeyboardButtonTypeCallback("help_twitch".encode()) 87 | ) 88 | ] 89 | ]) 90 | 91 | 92 | 93 | @Client.on_message(filters=Filter.command(["start", "help"])) 94 | @fsub 95 | async def welcome(c: Client, message: types.Message): 96 | bot_username = c.me.usernames.editable_username 97 | bot_name = c.me.first_name 98 | 99 | reply = await message.reply_text( 100 | StartMessage.format(bot_name=bot_name, bot_username=bot_username), 101 | parse_mode="html", 102 | disable_web_page_preview=True, 103 | reply_markup=get_main_menu_keyboard(bot_username) 104 | ) 105 | 106 | if isinstance(reply, types.Error): 107 | c.logger.warning(f"Error sending start/help message: {reply.message}") 108 | 109 | 110 | 111 | @Client.on_message(filters=Filter.command("privacy")) 112 | async def privacy_handler(_: Client, message: types.Message): 113 | await message.reply_text( 114 | "🔒 Privacy Policy\n\n" 115 | "This bot does not store any personal data or chat history.\n" 116 | "All queries are processed in real time and nothing is logged.\n\n" 117 | "🛠️ Open Source — You can inspect and contribute:\n" 118 | "github.com/AshokShau/SpTubeBot", 119 | parse_mode="html", 120 | disable_web_page_preview=True 121 | ) 122 | 123 | 124 | 125 | @Client.on_message(filters=Filter.command("ping")) 126 | async def ping_cmd(client: Client, message: types.Message) -> None: 127 | start_time = time.monotonic() 128 | reply_msg = await message.reply_text("🏓 Pinging...") 129 | latency = (time.monotonic() - start_time) * 1000 # in ms 130 | uptime = datetime.now() - StartTime 131 | uptime_str = str(uptime).split(".")[0] 132 | 133 | response = ( 134 | "📊 System Performance Metrics\n\n" 135 | f"⏱️ Bot Latency: {latency:.2f} ms\n" 136 | f"⏱️ Uptime: {uptime_str}\n" 137 | f"👤 Developer: @AshokShau" 138 | ) 139 | done = await reply_msg.edit_text(response, disable_web_page_preview=True) 140 | if isinstance(done, types.Error): 141 | client.logger.warning(f"Error sending message: {done}") 142 | return None 143 | 144 | @Client.on_message(filters=Filter.command("math")) 145 | async def math_cmd(client: Client, message: types.Message): 146 | parts = message.text.split(" ", 1) 147 | if len(parts) < 2: 148 | await message.reply_text("Please provide a search query.") 149 | return None 150 | 151 | query = parts[1] 152 | client.logger.info(f"Processing math query: {query}") 153 | result = await ApiData(query).evaluate() 154 | if isinstance(result, types.Error): 155 | await message.reply_text(result.message or "An error occurred.") 156 | return None 157 | 158 | await message.reply_text(result) 159 | return None 160 | -------------------------------------------------------------------------------- /src/modules/_fsub.py: -------------------------------------------------------------------------------- 1 | from typing import TypeAlias, Union, Callable, Awaitable 2 | from functools import wraps 3 | from pytdbot import Client, types 4 | from src.config import FSUB_ID 5 | 6 | ChatMemberStatus: TypeAlias = Union[ 7 | types.ChatMemberStatusCreator, 8 | types.ChatMemberStatusAdministrator, 9 | types.ChatMemberStatusMember, 10 | types.ChatMemberStatusRestricted, 11 | types.ChatMemberStatusLeft, 12 | types.ChatMemberStatusBanned, 13 | ] 14 | 15 | BLOCKED_STATUSES = { 16 | types.ChatMemberStatusLeft().getType(), 17 | types.ChatMemberStatusBanned().getType(), 18 | types.ChatMemberStatusRestricted().getType(), 19 | } 20 | 21 | # Caches 22 | member_status_cache = {} 23 | invite_link_cache: dict[int, str] = {} 24 | 25 | def fsub(func: Callable[..., Awaitable]): 26 | @wraps(func) 27 | async def wrapper(client: Client, message: types.Message, *args, **kwargs): 28 | chat_id = message.chat_id 29 | 30 | # Groups don't require FSUB 31 | if chat_id < 0: 32 | return await func(client, message, *args, **kwargs) 33 | 34 | # FSUB disabled 35 | if not FSUB_ID or FSUB_ID == 0: 36 | return await func(client, message, *args, **kwargs) 37 | 38 | user_id = message.from_id 39 | cached_status = member_status_cache.get(user_id) 40 | 41 | # If user is already verified, skip FSUB check 42 | if cached_status and cached_status not in BLOCKED_STATUSES: 43 | return await func(client, message, *args, **kwargs) 44 | 45 | # Get member status 46 | member = await client.getChatMember( 47 | chat_id=FSUB_ID, 48 | member_id=types.MessageSenderUser(user_id) 49 | ) 50 | if isinstance(member, types.Error) or member.status is None: 51 | if member.code == 400 and member.message == "Chat not found": 52 | client.logger.warning(f"❌ FSUB group not found: {FSUB_ID}") 53 | return await func(client, message, *args, **kwargs) 54 | status_type = types.ChatMemberStatusLeft().getType() 55 | else: 56 | status_type = member.status.getType() 57 | 58 | # Save verified users in cache 59 | if status_type not in BLOCKED_STATUSES: 60 | member_status_cache[user_id] = status_type 61 | return await func(client, message, *args, **kwargs) 62 | 63 | # Get invite link from cache or API 64 | invite_link = invite_link_cache.get(FSUB_ID) 65 | if not invite_link: 66 | _chat_id = int(str(FSUB_ID)[4:]) if str(FSUB_ID).startswith("-100") else FSUB_ID 67 | chat_info = await client.getSupergroupFullInfo(_chat_id) 68 | if isinstance(chat_info, types.Error): 69 | client.logger.warning(f"❌ Failed to get supergroup info: {chat_info.message}") 70 | return await func(client, message, *args, **kwargs) 71 | 72 | invite_link = getattr(chat_info.invite_link, "invite_link", None) 73 | if invite_link: 74 | invite_link_cache[FSUB_ID] = invite_link 75 | else: 76 | client.logger.warning(f"❌ No invite link found for: {FSUB_ID}") 77 | return await func(client, message, *args, **kwargs) 78 | 79 | # Send FSUB message 80 | text = ( 81 | "🔒 Channel Membership Required\n\n" 82 | "You need to join our channel to use me in private chat.\n" 83 | "✅ Once you’ve joined, type /start here to activate me.\n\n" 84 | "💬 In groups, you can use me without a subscription." 85 | ) 86 | button = types.ReplyMarkupInlineKeyboard( 87 | [[types.InlineKeyboardButton(text="📢 Join Channel", 88 | type=types.InlineKeyboardButtonTypeUrl(url=invite_link))]] 89 | ) 90 | 91 | return await message.reply_text( 92 | text=text, 93 | parse_mode="html", 94 | disable_web_page_preview=True, 95 | reply_markup=button, 96 | ) 97 | 98 | return wrapper 99 | 100 | 101 | def is_valid_supergroup(chat_id: int) -> bool: 102 | """ 103 | Check if a chat ID is for a supergroup. 104 | """ 105 | return str(chat_id).startswith("-100") 106 | 107 | async def _validate_chat(chat_id: int) -> bool: 108 | """Validate if chat is a supergroup and handle non-supergroups.""" 109 | return bool(is_valid_supergroup(chat_id)) 110 | 111 | @Client.on_updateChatMember() 112 | async def chat_member(client: Client, update: types.UpdateChatMember) -> None: 113 | """Handles member updates in the chat (joins, leaves, promotions, etc.).""" 114 | chat_id = update.chat_id 115 | 116 | # Early return for non-group chats 117 | if chat_id > 0 or not await _validate_chat(chat_id): 118 | return None 119 | 120 | if FSUB_ID != chat_id: 121 | return None 122 | 123 | user_id = update.new_chat_member.member_id.user_id 124 | old_status = update.old_chat_member.status["@type"] 125 | new_status = update.new_chat_member.status["@type"] 126 | 127 | # Handle different status change scenarios 128 | await _handle_status_changes(client, chat_id, user_id, old_status, new_status) 129 | return None 130 | 131 | async def _handle_status_changes( 132 | client: Client, chat_id: int, user_id: int, old_status: str, new_status: str 133 | ) -> None: 134 | """Route different status change scenarios to appropriate handlers.""" 135 | if old_status == "chatMemberStatusLeft" and new_status in { 136 | "chatMemberStatusMember", 137 | "chatMemberStatusAdministrator", 138 | }: 139 | await _handle_join(client, chat_id, user_id) 140 | elif ( 141 | old_status in {"chatMemberStatusMember", "chatMemberStatusAdministrator"} 142 | and new_status == "chatMemberStatusLeft" 143 | ): 144 | await _handle_leave_or_kick(client, chat_id, user_id) 145 | elif new_status == "chatMemberStatusBanned": 146 | await _handle_ban(chat_id, user_id) 147 | elif ( 148 | old_status == "chatMemberStatusBanned" and new_status == "chatMemberStatusLeft" 149 | ): 150 | await _handle_unban(chat_id, user_id) 151 | 152 | async def _handle_join(client: Client, chat_id: int, user_id: int) -> None: 153 | """Handle user/bot joining the chat.""" 154 | member_status_cache[user_id] = types.ChatMemberStatusMember().getType() 155 | 156 | async def _handle_leave_or_kick(client: Client, chat_id: int, user_id: int) -> None: 157 | """Handle user leaving or being kicked from chat.""" 158 | member_status_cache[user_id] = types.ChatMemberStatusLeft().getType() 159 | 160 | async def _handle_ban(chat_id: int, user_id: int) -> None: 161 | """Handle user being banned from chat.""" 162 | member_status_cache[user_id] = types.ChatMemberStatusBanned().getType() 163 | 164 | 165 | async def _handle_unban(chat_id: int, user_id: int) -> None: 166 | """Handle user being unbanned from chat.""" 167 | member_status_cache[user_id] = types.ChatMemberStatusLeft().getType() 168 | -------------------------------------------------------------------------------- /.github/workflows/dependency-update.yml: -------------------------------------------------------------------------------- 1 | name: Batch Dependency Updates 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | env: 9 | BRANCH_NAME: deps/batch-updates 10 | 11 | jobs: 12 | batch-update: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | pull-requests: write 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Set up Git user 24 | run: | 25 | git config --global user.name "AshokShau" 26 | git config --global user.email "114943948+AshokShau@users.noreply.github.com" 27 | 28 | - name: Set up Python 29 | uses: actions/setup-python@v4 30 | with: 31 | python-version: '3.11' 32 | 33 | - name: Install dependencies 34 | run: | 35 | python -m pip install --upgrade pip 36 | pip install uv jq tomli tomli-w packaging 37 | uv venv .venv 38 | source .venv/bin/activate 39 | uv pip install tomli tomli-w packaging 40 | 41 | - name: Get package versions 42 | id: get-versions 43 | run: | 44 | source .venv/bin/activate 45 | uv pip install -e . 46 | 47 | ALL_PKGS=$(uv pip list --format=json) 48 | OUTDATED=$(uv pip list --outdated --format=json) 49 | VERSION_MAP=$(jq -n --argjson all "$ALL_PKGS" --argjson outdated "$OUTDATED" ' 50 | ($all | map({(.name): {version: .version}})) + 51 | ($outdated | map({(.name): {latest_version: .latest_version}})) 52 | | add 53 | | with_entries(.value |= (.version // .latest_version // "")) 54 | ') 55 | 56 | ENCODED_VERS=$(echo "$VERSION_MAP" | base64 -w0) 57 | echo "versions_b64=${ENCODED_VERS}" >> $GITHUB_OUTPUT 58 | 59 | COUNT=$(echo "$OUTDATED" | jq -r 'length') 60 | echo "count=${COUNT}" >> $GITHUB_OUTPUT 61 | 62 | PKG_MD=$(echo "$OUTDATED" | jq -r '.[] | "| \(.name) | \(.version) | \(.latest_version) |"') 63 | echo "pkg_list_markdown<> $GITHUB_OUTPUT 64 | echo "$PKG_MD" >> $GITHUB_OUTPUT 65 | echo "EOF" >> $GITHUB_OUTPUT 66 | 67 | - name: Update pyproject.toml 68 | id: update-deps 69 | run: | 70 | source .venv/bin/activate 71 | python <> $GITHUB_OUTPUT 131 | git diff -U0 pyproject.toml | grep '^[+-][^+-]' >> $GITHUB_OUTPUT || true 132 | echo "EOF" >> $GITHUB_OUTPUT 133 | 134 | - name: Sync lockfile 135 | if: steps.update-deps.outputs.updates_made == 'true' 136 | run: | 137 | source .venv/bin/activate 138 | uv sync --upgrade 139 | echo "Lockfile updated via uv sync" 140 | 141 | - name: Generate commit message 142 | if: steps.update-deps.outputs.updates_made == 'true' 143 | id: commit-message 144 | run: | 145 | COUNT=$(echo '${{ steps.update-deps.outputs.updated_packages }}' | jq length) 146 | echo "commit_title=chore(deps): update $COUNT packages" >> $GITHUB_OUTPUT 147 | CHANGES=$(echo '${{ steps.update-deps.outputs.updated_packages }}' | jq -r '.[] | "- \(.name) \(.old) → \(.new)"') 148 | echo "commit_body<> $GITHUB_OUTPUT 149 | echo "$CHANGES" >> $GITHUB_OUTPUT 150 | echo "EOF" >> $GITHUB_OUTPUT 151 | 152 | - name: Create Pull Request 153 | if: steps.update-deps.outputs.updates_made == 'true' 154 | uses: peter-evans/create-pull-request@v5 155 | with: 156 | title: "${{ steps.commit-message.outputs.commit_title }}" 157 | body: | 158 | Automated dependency updates: 159 | 160 | ${{ steps.commit-message.outputs.commit_body }} 161 | 162 | Diff: 163 | ```diff 164 | ${{ steps.get-diff.outputs.diff }} 165 | ``` 166 | branch: "${{ env.BRANCH_NAME }}" 167 | commit-message: "${{ steps.commit-message.outputs.commit_title }}" 168 | committer: "AshokShau <114943948+AshokShau@users.noreply.github.com>" 169 | author: "AshokShau <114943948+AshokShau@users.noreply.github.com>" 170 | delete-branch: false 171 | branch-suffix: "" 172 | title-prefix: "" 173 | 174 | - name: No updates found 175 | if: steps.update-deps.outputs.updates_made == 'false' 176 | run: | 177 | echo "No dependency updates available at this time." 178 | 179 | -------------------------------------------------------------------------------- /src/modules/_utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from pytdbot import types, Client 4 | 5 | 6 | async def has_audio_stream(url: str) -> bool: 7 | cmd = [ 8 | 'ffprobe', 9 | '-v', 'error', 10 | '-select_streams', 'a', 11 | '-show_entries', 'stream=index', 12 | '-of', 'csv=p=0', 13 | '-user_agent', 'Mozilla/5.0', 14 | url 15 | ] 16 | 17 | try: 18 | process = await asyncio.create_subprocess_exec( 19 | *cmd, 20 | stdout=asyncio.subprocess.PIPE, 21 | stderr=asyncio.subprocess.PIPE 22 | ) 23 | 24 | stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=10) 25 | 26 | return bool(stdout.strip()) 27 | except Exception as e: 28 | print(f"Error checking audio stream: {e}") 29 | return False 30 | 31 | 32 | StartMessage = ( 33 | "🎧 Welcome to {bot_name}!\n" 34 | "Your quick and easy tool to download music & media from top platforms.\n\n" 35 | "📩 Just send a song name, link, or media URL.\n" 36 | "🔎 Search inline: @{bot_username} your search\n\n" 37 | "🔐 Privacy policy: /privacy\n" 38 | "📺 Download videos: /yt url\n" 39 | "🎵 Get Spotify playlists: /playlist url\n" 40 | ) 41 | 42 | async def handle_help_callback(_: Client, message: types.UpdateNewCallbackQuery): 43 | data = message.payload.data.decode() 44 | platform = data.replace("help_", "") 45 | 46 | examples = { 47 | "spotify": ( 48 | "💡Spotify Downloader\n\n" 49 | "🔹 Download songs, albums, and playlists in 320kbps quality\n" 50 | "🔹 Supports both public and private links\n\n" 51 | "Example formats:\n" 52 | "👉 https://open.spotify.com/track/* (Single song)\n" 53 | "👉 https://open.spotify.com/album/* (Full album)\n" 54 | "👉 https://open.spotify.com/playlist/* (Playlist)\n" 55 | "👉 https://open.spotify.com/artist/* (Artist's top tracks)" 56 | ), 57 | "youtube": ( 58 | "💡YouTube Downloader\n\n" 59 | "🔹 Download videos or extract audio\n" 60 | "🔹 Supports both YouTube and YouTube Music links\n\n" 61 | "Example formats:\n" 62 | "👉 https://youtu.be/* (Short URL)\n" 63 | "👉 https://www.youtube.com/watch?v=* (Full URL)\n" 64 | "👉 https://music.youtube.com/watch?v=* (YouTube Music)" 65 | ), 66 | "soundcloud": ( 67 | "💡SoundCloud Downloader\n\n" 68 | "🔹 Download tracks in high-quality\n" 69 | "🔹 Supports both public and private tracks\n\n" 70 | "Example formats:\n" 71 | "👉 https://soundcloud.com/user/track-name\n" 72 | "👉 https://soundcloud.com/user/track-name?utm_source=* (With tracking params)" 73 | ), 74 | "apple": ( 75 | "💡Apple Music Downloader\n\n" 76 | "🔹 Lossless music downloads\n" 77 | "🔹 Supports songs, albums, and artists\n\n" 78 | "Example formats:\n" 79 | "👉 https://music.apple.com/*\n" 80 | "👉 https://music.apple.com/us/song/*\n" 81 | "👉 https://music.apple.com/us/album/*\n" 82 | "👉 https://music.apple.com/us/artist/*" 83 | ), 84 | "instagram": ( 85 | "💡Instagram Media Downloader\n\n" 86 | "🔹 Download Instagram posts, reels, and stories\n" 87 | "🔹 Supports both public and private accounts\n\n" 88 | "Example formats:\n" 89 | "👉 https://www.instagram.com/p/* (Posts)\n" 90 | "👉 https://www.instagram.com/reel/* (Reels)\n" 91 | "👉 https://www.instagram.com/stories/* (Stories\n)" 92 | "Download Reels, Stories, and Posts:\n\n" 93 | "👉 https://www.instagram.com/reel/Cxyz123/" 94 | ), 95 | "pinterest": ( 96 | "💡Pinterest Downloader\n\n" 97 | "Photos and videos are available to download:\n\n" 98 | "👉 https://www.pinterest.com/pin/1085649053904273177/" 99 | ), 100 | "facebook": ( 101 | "💡Facebook Downloader\n\n" 102 | "Works with videos from public pages:\n\n" 103 | "👉 https://www.facebook.com/watch/?v=123456789" 104 | ), 105 | "twitter": ( 106 | "💡Twitter Downloader\n\n" 107 | "Download videos or Photos from posts:\n\n" 108 | "👉 https://x.com/i/status/1951310276814578086\n" 109 | "👉 https://twitter.com/i/status/1951310276814578086\n" 110 | "👉 https://x.com/luismbat/status/1951307858764607604/photo/1" 111 | ), 112 | "tiktok": ( 113 | "💡TikTok Downloader\n\n" 114 | "Supports watermark-free download:\n\n" 115 | "👉 https://vt.tiktok.com/ZSB3BovQp/\n" 116 | "👉 https://vt.tiktok.com/ZSSe7NprD/" 117 | ), 118 | "threads": ( 119 | "💡Threads Downloader\n\n" 120 | "Download media from Threads:\n\n" 121 | "👉 https://www.threads.com/@camycavero/post/DM0FquaM2At?xmt=AQF0u_6ebeMHEjWCw0cm0Li4i8fI3INIU7YeSMffM9DmDw\n" 122 | ), 123 | "reddit": ( 124 | "💡Reddit Downloader\n\n" 125 | "Download media from Reddit:\n\n" 126 | "👉 https://www.reddit.com/r/tollywood/comments/1mld609/what_is_your_honest_unfiltered_opinion_on_mahesh/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button\n" 127 | "👉 https://www.reddit.com/r/Damnthatsinteresting/comments/1mlfgzv/when_cat_meets_cat/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button\n" 128 | "👉 https://www.reddit.com/r/Indian_flex/comments/1mlez7j/tough_life/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button\n" 129 | ), 130 | "twitch": ( 131 | "💡Twitch Clip Downloader\n\n" 132 | "Download media from Twitch:\n\n" 133 | "👉 https://www.twitch.tv/tarik/clip/CheerfulHonorableBibimbapHumbleLife-cdCV_zL45i1p2Kh6\n" 134 | ), 135 | } 136 | 137 | reply_text = examples.get(platform, "No help available for this platform.") 138 | await message.answer(text=f"{platform} Help Menu") 139 | await message.edit_message_text( 140 | text=reply_text, 141 | parse_mode="html", 142 | disable_web_page_preview=True, 143 | reply_markup=types.ReplyMarkupInlineKeyboard([ 144 | [ 145 | types.InlineKeyboardButton( 146 | text="⬅️ Back", 147 | type=types.InlineKeyboardButtonTypeCallback("back_menu".encode()) 148 | ) 149 | ] 150 | ]) 151 | ) 152 | -------------------------------------------------------------------------------- /src/modules/snap.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Union, List, Optional 3 | import asyncio 4 | 5 | from pytdbot import Client, types 6 | from pytdbot.exception import StopHandlers 7 | 8 | from src.utils import ApiData, Filter, APIResponse, Download 9 | 10 | from ._fsub import fsub 11 | from ._utils import has_audio_stream 12 | 13 | 14 | 15 | def batch_chunks(items: List[str], size: int = 10) -> List[List[str]]: 16 | return [items[i:i + size] for i in range(0, len(items), size)] 17 | 18 | 19 | async def _handle_media_upload( 20 | client: Client, 21 | message: types.Message, 22 | media_url: str, 23 | media_type: str, 24 | reply_message: types.Message 25 | ) -> Optional[types.Error]: 26 | input_file = types.InputFileRemote(media_url) 27 | send_func = { 28 | "photo": message.reply_photo, 29 | "video": message.reply_video, 30 | "animation": message.reply_animation 31 | }.get(media_type) 32 | 33 | if not send_func: 34 | return types.Error(message="Unsupported media type") 35 | 36 | result = await send_func(**{media_type: input_file}) 37 | if isinstance(result, types.Error) and "WEBPAGE_CURL_FAILED" in result.message: 38 | file_ext = ".mp4" if media_type in {"video", "animation"} else ".jpg" 39 | file_name = f"{uuid.uuid4()}{file_ext}" 40 | local_file = await Download(None).download_file(media_url, file_name) 41 | if isinstance(local_file, types.Error): 42 | client.logger.warning(f"❌ Media download failed: {local_file.message}") 43 | return local_file 44 | 45 | input_file_local = types.InputFileLocal(local_file) 46 | result = await send_func(**{media_type: input_file_local}) 47 | 48 | if isinstance(result, types.Error): 49 | client.logger.warning(f"❌ Media upload failed: {result.message}") 50 | return result 51 | 52 | return None 53 | 54 | 55 | async def _send_media_album( 56 | client: Client, 57 | message: types.Message, 58 | media_urls: List[str], 59 | media_type: str 60 | ) -> Optional[types.Error]: 61 | content_cls = { 62 | "photo": types.InputMessagePhoto, 63 | "video": types.InputMessageVideo, 64 | "animation": types.InputMessageAnimation 65 | }.get(media_type) 66 | 67 | if not content_cls: 68 | return types.Error(message="Unsupported media type") 69 | 70 | contents = [ 71 | content_cls(**{media_type: types.InputFileRemote(url)}) 72 | for url in media_urls 73 | ] 74 | 75 | result = await client.sendMessageAlbum( 76 | chat_id=message.chat_id, 77 | input_message_contents=contents, 78 | reply_to=types.InputMessageReplyToMessage(message_id=message.id), 79 | ) 80 | 81 | if isinstance(result, types.Error): 82 | client.logger.warning(f"❌ Media album upload failed: {result.message}") 83 | return result 84 | 85 | return None 86 | 87 | 88 | @Client.on_message(filters=Filter.command("insta")) 89 | @fsub 90 | async def insta_cmd(client: Client, message: types.Message) -> None: 91 | parts = message.text.split(" ", 1) 92 | if len(parts) < 2 or not parts[1].strip(): 93 | await message.reply_text("Please provide a valid search query.") 94 | return None 95 | 96 | api = ApiData(parts[1].strip()) 97 | valid_url = api.extract_save_snap_url() 98 | if not valid_url: 99 | await message.reply_text("Please provide a valid search query.") 100 | return None 101 | 102 | await process_insta_query(client, message, valid_url) 103 | raise StopHandlers 104 | 105 | @Client.on_message(filters=Filter.save_snap()) 106 | @fsub 107 | async def insta_autodetect(client: Client, message: types.Message): 108 | api = ApiData(message.text.strip()) 109 | valid_url = api.extract_save_snap_url() 110 | if not valid_url: 111 | return None 112 | await process_insta_query(client, message, valid_url) 113 | raise StopHandlers 114 | 115 | 116 | async def process_insta_query(client: Client, message: types.Message, query: str) -> None: 117 | reply = await message.reply_text("⏳ Processing...") 118 | api = ApiData(query) 119 | api_data: Union[APIResponse, types.Error, None] = await api.get_snap() 120 | 121 | if isinstance(api_data, types.Error) or not api_data: 122 | await reply.edit_text(f"❌ Error: {api_data.message if api_data else 'No results found'}") 123 | return 124 | 125 | # --- Handle Images --- 126 | if api_data.image: 127 | for batch in batch_chunks(api_data.image, 10): 128 | error = await ( 129 | _handle_media_upload(client, message, batch[0], "photo", reply) 130 | if len(batch) == 1 131 | else _send_media_album(client, message, batch, "photo") 132 | ) 133 | 134 | if error: 135 | await reply.edit_text(f"❌ Failed to send photo(s): {error.message}") 136 | return 137 | await asyncio.sleep(1) 138 | 139 | # --- Handle Videos --- 140 | if api_data.video: 141 | video_urls = [v.video for v in api_data.video if v.video] 142 | if not video_urls: 143 | await reply.delete() 144 | return 145 | 146 | if len(video_urls) == 1: 147 | error = await _handle_media_upload(client, message, video_urls[0], "video", reply) 148 | if error: 149 | await reply.edit_text(f"❌ Failed to send video: {error.message}") 150 | else: 151 | await reply.delete() 152 | return 153 | 154 | # Check audio presence concurrently 155 | results = await asyncio.gather( 156 | *(has_audio_stream(url) for url in video_urls), 157 | return_exceptions=True 158 | ) 159 | 160 | videos_with_audio, videos_without_audio = [], [] 161 | for url, result in zip(video_urls, results): 162 | if isinstance(result, Exception): 163 | client.logger.warning(f"❌ Failed to check audio for {url}: {result}") 164 | continue 165 | (videos_with_audio if result else videos_without_audio).append(url) 166 | 167 | if not videos_with_audio and not videos_without_audio: 168 | await reply.edit_text("❌ No valid videos found.") 169 | return 170 | 171 | # Send videos with audio 172 | for batch in batch_chunks(videos_with_audio, 10): 173 | error = await ( 174 | _handle_media_upload(client, message, batch[0], "video", reply) 175 | if len(batch) == 1 176 | else _send_media_album(client, message, batch, "video") 177 | ) 178 | if error: 179 | await reply.edit_text(f"❌ Failed to send video(s): {error.message}") 180 | return 181 | await asyncio.sleep(1) 182 | 183 | if not videos_without_audio: 184 | await reply.delete() 185 | return 186 | 187 | # Send videos without audio as animations 188 | for batch in batch_chunks(videos_without_audio, 10): 189 | error = await ( 190 | _handle_media_upload(client, message, batch[0], "animation", reply) 191 | if len(batch) == 1 192 | else _send_media_album(client, message, batch, "animation") 193 | ) 194 | if error: 195 | await reply.edit_text(f"❌ Failed to send animation(s): {error.message}") 196 | return 197 | await asyncio.sleep(1) 198 | 199 | await reply.delete() 200 | return 201 | -------------------------------------------------------------------------------- /src/utils/_db.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from typing import Optional 4 | 5 | from pymongo import AsyncMongoClient 6 | from pytdbot import types 7 | 8 | 9 | from src.config import MONGO_URI, LOGGER_ID 10 | from ._dataclass import TrackInfo 11 | 12 | 13 | async def convert_to_m4a(input_file: str, cover_file: str, track: TrackInfo) -> str | None: 14 | """Convert audio to M4A with cover art and metadata.""" 15 | abs_input = os.path.abspath(input_file) 16 | abs_cover = os.path.abspath(cover_file) 17 | output_file = f"{os.path.splitext(abs_input)[0]}.m4a" 18 | 19 | cmd = [ 20 | "ffmpeg", "-y", 21 | "-i", abs_input, "-i", abs_cover, 22 | "-map", "0:a", "-map", "1:v", 23 | "-c:a", "aac", "-b:a", "192k", 24 | "-c:v", "png", 25 | "-metadata:s:v", "title=Album cover", 26 | "-metadata:s:v", "comment=Cover (front)", 27 | "-metadata", f"lyrics={track.lyrics}", 28 | "-metadata", f"title={track.name}", 29 | "-metadata", f"artist={track.artist}", 30 | "-metadata", f"album={track.album}", 31 | "-metadata", f"year={track.year}", 32 | "-metadata", "genre=Spotify", 33 | "-metadata", "comment=Via NoiNoi_bot | FallenProjects", 34 | "-f", "mp4", 35 | output_file, 36 | ] 37 | 38 | proc = await asyncio.create_subprocess_exec( 39 | *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE 40 | ) 41 | stdout, stderr = await proc.communicate() 42 | 43 | if proc.returncode != 0: 44 | print(f"❌ ffmpeg failed:\n{stderr.decode(errors='ignore')}") 45 | print(f"🔍 stdout:\n{stdout.decode(errors='ignore')}") 46 | return None 47 | 48 | return output_file 49 | 50 | 51 | 52 | class MongoDB: 53 | def __init__(self): 54 | self.mongo_client = AsyncMongoClient(MONGO_URI) 55 | self._db = self.mongo_client["SpTube"] 56 | self.songs = self._db["songs"] 57 | self.logger_chat_id = LOGGER_ID 58 | self._cache: dict[str, str] = {} 59 | 60 | async def connect(self) -> None: 61 | """Establish connection to MongoDB and load cache.""" 62 | await self.mongo_client.aconnect() 63 | try: 64 | await self.mongo_client.admin.command("ping") 65 | except Exception as e: 66 | raise e 67 | await self._load_cache() 68 | 69 | async def _load_cache(self) -> None: 70 | """Load all stored songs into in-memory cache.""" 71 | async for song in self.songs.find(): 72 | self._cache[song["_id"]] = song["link"] 73 | 74 | async def store_song_link(self, track_id: str, link: str) -> None: 75 | """Store or update a song link in MongoDB and cache.""" 76 | await self.songs.update_one({"_id": track_id}, {"$set": {"link": link}}, upsert=True) 77 | self._cache[track_id] = link 78 | 79 | async def get_song_link(self, track_id: str) -> Optional[str]: 80 | """Retrieve song link from cache or MongoDB.""" 81 | if track_id in self._cache: 82 | return self._cache[track_id] 83 | 84 | song = await self.songs.find_one({"_id": track_id}) 85 | if song: 86 | self._cache[track_id] = song["link"] 87 | return song["link"] 88 | return None 89 | 90 | async def get_song_file_id(self, track_id: str) -> Optional[str]: 91 | """Retrieve the Telegram file ID for a stored song link.""" 92 | from src import client 93 | 94 | link = await self.get_song_link(track_id) 95 | if not link: 96 | return None 97 | 98 | info = await client.getMessageLinkInfo(url=link) 99 | if isinstance(info, types.Error) or not info.message: 100 | client.logger.warning(f"❌ Failed to get message link info: {getattr(info, 'message', info)}") 101 | return None 102 | 103 | msg = await client.getMessage(info.chat_id, info.message.id) 104 | if isinstance(msg, types.Error): 105 | client.logger.warning(f"❌ Failed to get message: {msg.message}") 106 | return None 107 | 108 | content = msg.content 109 | if isinstance(content, types.MessageAudio): 110 | return content.audio.audio.remote.id 111 | elif isinstance(content, types.MessageDocument): 112 | return content.document.document.remote.id 113 | elif isinstance(content, types.MessageVideo): 114 | return content.video.video.remote.id 115 | 116 | client.logger.warning(f"❌ Unsupported media type in stored link: {content}") 117 | await self.remove_song(track_id) 118 | return None 119 | 120 | async def upload_song_and_get_file_id( 121 | self, file_path: str, cover: Optional[str], track: TrackInfo 122 | ) -> Optional[str] | types.Error: 123 | """Upload song to logger chat, store link, and return file ID.""" 124 | from src import client 125 | 126 | thumb = types.InputThumbnail(thumbnail=types.InputFileLocal(cover) if cover else types.InputFileRemote(track.cover), width=640, height=640) 127 | async def _send(path: str): 128 | return await client.sendAudio( 129 | chat_id=self.logger_chat_id, 130 | audio=types.InputFileLocal(path), 131 | album_cover_thumbnail=thumb, 132 | title=track.name, 133 | performer=track.artist, 134 | duration=track.duration, 135 | caption=f"{track.name}\n{track.artist}", 136 | ) 137 | 138 | upload = await _send(file_path) 139 | 140 | # Handle "uploaded as voice" issue 141 | if not isinstance(upload, types.Error) and isinstance(upload.content, types.MessageVoiceNote): 142 | fixed_path = await convert_to_m4a(file_path, cover, track) 143 | if not fixed_path: 144 | public_link = await client.getMessageLink(upload.chat_id, upload.id) 145 | return types.Error( 146 | message=f"Failed to upload audio - here is your song: {public_link.link or 'No Link 2x sed moment'}" 147 | ) 148 | 149 | await upload.delete() 150 | upload = await _send(fixed_path) 151 | 152 | try: 153 | os.remove(fixed_path) 154 | except Exception as e: 155 | client.logger.warning(f"❌ Failed to remove converted file: {e}") 156 | 157 | if isinstance(upload, types.Error): 158 | client.logger.warning(f"❌ Failed to upload audio: {upload.message}") 159 | return upload 160 | 161 | public_link = await client.getMessageLink(upload.chat_id, upload.id) 162 | if isinstance(public_link, types.Error): 163 | client.logger.warning(f"❌ Failed to get public link: {public_link.message}") 164 | return public_link 165 | 166 | try: 167 | os.remove(file_path) 168 | except Exception as e: 169 | client.logger.warning(f"❌ Failed to remove original file: {e}") 170 | 171 | if isinstance(upload.content, types.MessageAudio): 172 | await self.store_song_link(track.tc, public_link.link) 173 | return upload.content.audio.audio.remote.id 174 | 175 | client.logger.info(f"file_path: {file_path} | cover: {cover}") 176 | client.logger.warning(f"❌ Unsupported media type in uploaded audio: {upload}") 177 | return types.Error( 178 | message=f"Failed to upload audio - here is your song: {public_link.link}" 179 | ) 180 | 181 | async def remove_song(self, track_id: str) -> None: 182 | """Remove song from MongoDB and cache.""" 183 | await self.songs.delete_one({"_id": track_id}) 184 | if track_id in self._cache: 185 | del self._cache[track_id] 186 | 187 | async def close(self) -> None: 188 | """Close MongoDB connection and clear cache.""" 189 | await self.mongo_client.aclose() 190 | self._cache.clear() 191 | 192 | 193 | # Global DB instance 194 | db: MongoDB = MongoDB() 195 | -------------------------------------------------------------------------------- /src/utils/_api.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urllib.parse 3 | from typing import Dict, Optional, Union, Type, TypeVar 4 | import httpx 5 | from pytdbot import types 6 | from src import config 7 | from src.utils._dataclass import PlatformTracks, TrackInfo, MusicTrack, APIResponse 8 | 9 | # === Constants === 10 | DOWNLOAD_TIMEOUT = 300.0 11 | CONNECT_TIMEOUT = 30.0 12 | DEFAULT_LIMIT = "10" 13 | MAX_QUERY_LENGTH = 500 14 | MAX_URL_LENGTH = 1000 15 | 16 | HEADER_ACCEPT = "Accept" 17 | HEADER_API_KEY = "X-API-Key" 18 | MIME_APPLICATION = "application/json" 19 | 20 | MAX_CONCURRENT_DOWNLOADS = 5 21 | 22 | T = TypeVar("T") 23 | 24 | # === URL Regex Patterns === 25 | URL_PATTERNS = { 26 | "spotify": re.compile( 27 | r'^(https?://)?([a-z0-9-]+\.)*spotify\.com/(track|playlist|album|artist)/[a-zA-Z0-9]+(\?.*)?$'), 28 | "youtube": re.compile(r'^(https?://)?([a-z0-9-]+\.)*(youtube\.com/watch\?v=|youtu\.be/)[\w-]+(\?.*)?$'), 29 | "youtube_music": re.compile(r'^(https?://)?([a-z0-9-]+\.)*youtube\.com/(watch\?v=|playlist\?list=)[\w-]+(\?.*)?$'), 30 | "soundcloud": re.compile(r'^(https?://)?([a-z0-9-]+\.)*soundcloud\.com/[\w-]+(/[\w-]+)?(/sets/[\w-]+)?(\?.*)?$'), 31 | "apple_music": re.compile( 32 | r'^(https?://)?([a-z0-9-]+\.)?apple\.com/[a-z]{2}/(album|playlist|song)/[^/]+/(pl\.[a-zA-Z0-9]+|\d+)(\?i=\d+)?(\?.*)?$') 33 | } 34 | 35 | # === Save Snap Regex Patterns === 36 | SAVE_SNAP_PATTERNS = [ 37 | re.compile( 38 | r"(?i)https?://(?:www\.)?(instagram\.com|instagr\.am)/(reel|reels|stories|p|tv|share)/[^\s/?]+", 39 | re.I, 40 | ), 41 | re.compile(r"(?i)https?://(?:[a-z]+\.)?(pinterest\.com|pin\.it)/[^\s]+"), 42 | re.compile(r"(?i)https?://(?:www\.)?fb\.watch/[^\s/?]+"), 43 | re.compile(r"(?i)https?://(?:www\.)?facebook\.com/(?:.+/videos/\d+|reel/\d+|share/r/[\w\d]+)/?"), 44 | re.compile( 45 | r"https?://(?:www\.|m\.)?(?:vt\.)?tiktok\.com/(?:@[\w.-]+/(?:video|photo)/\d+|v/\d+\.html|t/[\w]+|[\w]+)", 46 | re.I, 47 | ), 48 | re.compile(r"https?://(?:www\.)?(?:x|twitter)\.com/[^\s]+", re.I), 49 | re.compile( 50 | r"https?://(?:www\.)?threads\.(?:com|net)/@[\w.-]+/post/[\w-]+(?:\?[\w=&%-]+)?", 51 | re.I, 52 | ), 53 | re.compile( 54 | r"https?://(?:(?:www|old)\.)?reddit\.com/r/[\w]+/(?:(?:comments/[\w]+(?:/[^\s]*)?)|(?:s/[\w]+))" 55 | r"|https?://redd\.it/[\w]+", 56 | re.I, 57 | ), 58 | re.compile( 59 | r"https?://(?:clips\.twitch\.tv/|(?:www\.)?twitch\.tv/[^/]+/clip/)([\w-]+(?:-\w+)*)", 60 | re.I, 61 | ), 62 | ] 63 | 64 | 65 | _client: Optional[httpx.AsyncClient] = None 66 | class HttpClient: 67 | """Singleton Async HTTP client.""" 68 | @staticmethod 69 | async def get_client() -> httpx.AsyncClient: 70 | global _client 71 | if _client is None or _client.is_closed: 72 | _client = httpx.AsyncClient( 73 | timeout=httpx.Timeout( 74 | connect=CONNECT_TIMEOUT, 75 | read=DOWNLOAD_TIMEOUT, 76 | write=DOWNLOAD_TIMEOUT, 77 | pool=None 78 | ), 79 | limits=httpx.Limits( 80 | max_connections=MAX_CONCURRENT_DOWNLOADS, 81 | max_keepalive_connections=MAX_CONCURRENT_DOWNLOADS 82 | ), 83 | follow_redirects=True, 84 | trust_env=True 85 | ) 86 | return _client 87 | 88 | @staticmethod 89 | async def close_client(): 90 | global _client 91 | if _client: 92 | await _client.aclose() 93 | _client = None 94 | 95 | 96 | class ApiData: 97 | def __init__(self, query: str): 98 | self.api_url = config.API_URL 99 | self.query = self._sanitize_input(query.strip()) if query else "" 100 | 101 | # --- Validation --- 102 | def is_valid(self) -> bool: 103 | if not self.query or len(self.query) > MAX_URL_LENGTH: 104 | return False 105 | if not re.match("^https?://", self.query.strip()): 106 | return False 107 | 108 | try: 109 | parsed = urllib.parse.urlparse(self.query) 110 | if not (parsed.scheme and parsed.netloc): 111 | return False 112 | except ValueError: 113 | return False 114 | return any(p.search(self.query) for p in URL_PATTERNS.values()) 115 | 116 | def extract_save_snap_url(self) -> Optional[str]: 117 | for regex in SAVE_SNAP_PATTERNS: 118 | if match := regex.search(self.query): 119 | return match.group(0) 120 | return None 121 | 122 | def is_save_snap_url(self) -> bool: 123 | return bool(self.extract_save_snap_url()) 124 | 125 | # --- API Methods --- 126 | async def get_info(self) -> Union[types.Error, PlatformTracks]: 127 | if not self.is_valid(): 128 | return types.Error(message="Url is not valid") 129 | return await self._request_json( 130 | f"{self.api_url}/get_url?url={urllib.parse.quote(self.query)}", 131 | PlatformTracks, list_key="results", item_model=MusicTrack 132 | ) 133 | 134 | async def search(self, limit: str = DEFAULT_LIMIT) -> Union[types.Error, PlatformTracks]: 135 | return await self._request_json( 136 | f"{self.api_url}/search?query={urllib.parse.quote(self.query)}&limit={urllib.parse.quote(limit)}", 137 | PlatformTracks, list_key="results", item_model=MusicTrack 138 | ) 139 | 140 | async def get_track(self) -> Union[types.Error, TrackInfo]: 141 | endpoint = f"{self.api_url}/track?url={urllib.parse.quote(self.query)}" 142 | return await self._request_json(endpoint, TrackInfo) 143 | 144 | async def get_snap(self) -> Union[types.Error, APIResponse]: 145 | if not self.is_save_snap_url(): 146 | return types.Error(message="Url is not valid") 147 | return await self._request_json( 148 | f"{self.api_url}/snap?url={urllib.parse.quote(self.query)}", 149 | APIResponse 150 | ) 151 | 152 | async def evaluate(self) -> Union[str, "types.Error"]: 153 | query = urllib.parse.quote(self.query) 154 | endpoint = f"https://evaluate-expression.p.rapidapi.com/?expression={query}" 155 | client = await HttpClient.get_client() 156 | headers = { 157 | "x-rapidapi-host": "evaluate-expression.p.rapidapi.com", 158 | "x-rapidapi-key": "cf9e67ea99mshecc7e1ddb8e93d1p1b9e04jsn3f1bb9103c3f", 159 | } 160 | try: 161 | response = await client.get(endpoint, headers=headers) 162 | response.raise_for_status() 163 | body = response.text.strip() 164 | return body or types.Error(message="Invalid Math Expression") 165 | except Exception as e: 166 | return types.Error(message=f"Evaluation failed: {e}") 167 | 168 | # --- Helpers --- 169 | async def _request_json( 170 | self, endpoint: str, model: Type[T], 171 | list_key: Optional[str] = None, item_model: Optional[Type] = None 172 | ) -> Union[types.Error, T]: 173 | """Generic API request -> model parser""" 174 | client = await HttpClient.get_client() 175 | try: 176 | response = await client.get(endpoint, headers=self._get_headers()) 177 | response.raise_for_status() 178 | data = response.json() 179 | if list_key and item_model: 180 | items = [item_model(**x) for x in data.get(list_key, [])] 181 | return model(results=items) 182 | return model(**data) 183 | except httpx.HTTPStatusError as e: 184 | return types.Error(message=f"Request failed: {e.response.status_code}") 185 | except httpx.RequestError as e: 186 | return types.Error(message=f"HTTP error: {e}") 187 | except (ValueError, TypeError) as e: 188 | return types.Error(message=f"Invalid JSON: {e}") 189 | except Exception as e: 190 | return types.Error(message=f"Unexpected error: {e}") 191 | 192 | @staticmethod 193 | def _get_headers() -> Dict[str, str]: 194 | return {HEADER_API_KEY: config.API_KEY, HEADER_ACCEPT: MIME_APPLICATION} 195 | 196 | @staticmethod 197 | def _sanitize_input(input_str: str) -> str: 198 | return input_str[:MAX_QUERY_LENGTH] 199 | -------------------------------------------------------------------------------- /src/modules/owner.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2025 AshokShau 2 | # Licensed under the GNU AGPL v3.0: https://www.gnu.org/licenses/agpl-3.0.html 3 | # Part of the TgMusicBot project. All rights reserved where applicable. 4 | import asyncio 5 | import inspect 6 | import io 7 | import os 8 | import re 9 | import sys 10 | import traceback 11 | import uuid 12 | from html import escape 13 | from typing import Any, Optional, Tuple, Union 14 | 15 | from meval import meval 16 | from pytdbot import Client, types 17 | 18 | 19 | from src.config import OWNER_ID 20 | from src.utils import Filter 21 | 22 | 23 | def format_exception( 24 | exp: BaseException, tb: Optional[list[traceback.FrameSummary]] = None 25 | ) -> str: 26 | """ 27 | Formats an exception traceback as a string, similar to the Python interpreter. 28 | """ 29 | 30 | if tb is None: 31 | tb = traceback.extract_tb(exp.__traceback__) 32 | 33 | # Replace absolute paths with relative paths 34 | cwd = os.getcwd() 35 | for frame in tb: 36 | if cwd in frame.filename: 37 | frame.filename = os.path.relpath(frame.filename) 38 | 39 | stack = "".join(traceback.format_list(tb)) 40 | msg = str(exp) 41 | if msg: 42 | msg = f": {msg}" 43 | 44 | return f"Traceback (most recent call last):\n{stack}{type(exp).__name__}{msg}" 45 | 46 | 47 | @Client.on_message(filters=Filter.command("eval")) 48 | async def exec_eval(c: Client, m: types.Message) -> None: 49 | """ 50 | Run python code. 51 | """ 52 | user_id = m.from_id 53 | if user_id != OWNER_ID: 54 | return None 55 | 56 | 57 | 58 | text = m.text.split(None, 1) 59 | if len(text) <= 1: 60 | reply = await m.reply_text("Usage: /eval < code >") 61 | if isinstance(reply, types.Error): 62 | c.logger.warning(reply.message) 63 | return None 64 | 65 | code = text[1] 66 | out_buf = io.StringIO() 67 | 68 | async def _eval() -> Tuple[str, Optional[str]]: 69 | async def send( 70 | *args: Any, **kwargs: Any 71 | ) -> Union["types.Error", "types.Message"]: 72 | return await m.reply_text(*args, **kwargs) 73 | 74 | def _print(*args: Any, **kwargs: Any) -> None: 75 | if "file" not in kwargs: 76 | kwargs["file"] = out_buf 77 | return print(*args, **kwargs) 78 | return None 79 | 80 | eval_vars = { 81 | "loop": c.loop, 82 | "client": c, 83 | "stdout": out_buf, 84 | "c": c, 85 | "m": m, 86 | "msg": m, 87 | "types": types, 88 | "send": send, 89 | "print": _print, 90 | "inspect": inspect, 91 | "os": os, 92 | "re": re, 93 | "sys": sys, 94 | "traceback": traceback, 95 | "uuid": uuid, 96 | "io": io, 97 | } 98 | 99 | try: 100 | return "", await meval(code, globals(), **eval_vars) 101 | except Exception as e: 102 | first_snip_idx = -1 103 | tb = traceback.extract_tb(e.__traceback__) 104 | for i, frame in enumerate(tb): 105 | if frame.filename == "" or frame.filename.endswith("ast.py"): 106 | first_snip_idx = i 107 | break 108 | 109 | # Re-raise exception if it wasn't caused by the snippet 110 | if first_snip_idx == -1: 111 | raise e 112 | 113 | # Return formatted stripped traceback 114 | stripped_tb = tb[first_snip_idx:] 115 | formatted_tb = format_exception(e, tb=stripped_tb) 116 | return "⚠️ Error:\n\n", formatted_tb 117 | 118 | prefix, result = await _eval() 119 | 120 | if not out_buf.getvalue() or result is not None: 121 | print(result, file=out_buf) 122 | 123 | out = out_buf.getvalue() 124 | if out.endswith("\n"): 125 | out = out[:-1] 126 | 127 | result = f"""{prefix}In: 128 |
{escape(code)}
129 | ᴏᴜᴛ: 130 |
{escape(out)}
""" 131 | 132 | if len(result) > 2000: 133 | filename = f"database/{uuid.uuid4().hex}.txt" 134 | with open(filename, "w", encoding="utf-8") as file: 135 | file.write(out) 136 | 137 | caption = f"""{prefix}ᴇᴠᴀʟ: 138 |
{escape(code)}
139 | """ 140 | reply = await m.reply_document( 141 | document=types.InputFileLocal(filename), 142 | caption=caption, 143 | disable_notification=True, 144 | parse_mode="html", 145 | ) 146 | if isinstance(reply, types.Error): 147 | c.logger.warning(reply.message) 148 | 149 | if os.path.exists(filename): 150 | os.remove(filename) 151 | 152 | return None 153 | 154 | reply = await m.reply_text(str(result), parse_mode="html") 155 | if isinstance(reply, types.Error): 156 | c.logger.warning(reply.message) 157 | return None 158 | 159 | async def run_shell_command(cmd: str, timeout: int = 60) -> tuple[str, str, int]: 160 | """Execute shell command and return stdout, stderr, returncode.""" 161 | process = await asyncio.create_subprocess_shell( 162 | cmd, 163 | stdout=asyncio.subprocess.PIPE, 164 | stderr=asyncio.subprocess.PIPE, 165 | ) 166 | 167 | try: 168 | stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout) 169 | except asyncio.TimeoutError: 170 | process.kill() 171 | await process.wait() 172 | return "", f"Command timed out after {timeout} seconds", -1 173 | 174 | return stdout.decode().strip(), stderr.decode().strip(), process.returncode 175 | 176 | 177 | async def shellrunner(message: types.Message) -> types.Ok | types.Error | types.Message: 178 | text = message.text.split(None, 1) 179 | if len(text) <= 1: 180 | reply = await message.reply_text("Usage: /sh < cmd >") 181 | return reply if isinstance(reply, types.Error) else types.Ok() 182 | command = text[1] 183 | """ 184 | # Security check - prevent dangerous commands 185 | if any(blocked in command.lower() for blocked in [ 186 | 'rm -rf', 'sudo', 'dd ', 'mkfs', 'fdisk', 187 | ':(){:|:&};:', 'chmod 777', 'wget', 'curl' 188 | ]): 189 | return await message.reply_text("⚠️ Dangerous command blocked!") 190 | """ 191 | 192 | try: 193 | # Execute single command or multiple commands separated by newlines 194 | if "\n" in command: 195 | commands = [cmd.strip() for cmd in command.split("\n") if cmd.strip()] 196 | output_parts = [] 197 | 198 | for cmd in commands: 199 | stdout, stderr, retcode = await run_shell_command(cmd) 200 | 201 | output_parts.append(f"🚀 Command: {cmd}") 202 | if stdout: 203 | output_parts.append(f"📤 Output:\n
{stdout}
") 204 | if stderr: 205 | output_parts.append(f"❌ Error:\n
{stderr}
") 206 | output_parts.append(f"🔢 Exit Code: {retcode}\n") 207 | 208 | output = "\n".join(output_parts) 209 | else: 210 | stdout, stderr, retcode = await run_shell_command(command) 211 | 212 | output = f"🚀 Command: {command}\n" 213 | if stdout: 214 | output += f"📤 Output:\n
{stdout}
\n" 215 | if stderr: 216 | output += f"❌ Error:\n
{stderr}
\n" 217 | output += f"🔢 Exit Code: {retcode}" 218 | 219 | # Handle empty output 220 | if not output.strip(): 221 | output = "📭 No output was returned" 222 | 223 | if len(output) <= 2000: 224 | return await message.reply_text(str(output), parse_mode="html") 225 | 226 | filename = f"database/{uuid.uuid4().hex}.txt" 227 | with open(filename, "w", encoding="utf-8") as file: 228 | file.write(output) 229 | reply = await message.reply_document( 230 | document=types.InputFileLocal(filename), 231 | caption="📁 Output too large, sending as file:", 232 | disable_notification=True, 233 | parse_mode="html", 234 | ) 235 | if isinstance(reply, types.Error): 236 | return reply 237 | 238 | if os.path.exists(filename): 239 | os.remove(filename) 240 | 241 | return types.Ok() 242 | except Exception as e: 243 | return await message.reply_text( 244 | f"⚠️ Error:\n
{str(e)}
", parse_mode="html" 245 | ) 246 | 247 | 248 | @Client.on_message(filters=Filter.command("sh")) 249 | async def shell_command(c: Client, m: types.Message) -> None: 250 | user_id = m.from_id 251 | if user_id != OWNER_ID: 252 | return None 253 | 254 | done = await shellrunner(m) 255 | if isinstance(done, types.Error): 256 | c.logger.warning(done.message) 257 | return None 258 | return None 259 | -------------------------------------------------------------------------------- /src/modules/inline.py: -------------------------------------------------------------------------------- 1 | import re 2 | import uuid 3 | from html import escape 4 | from typing import Union 5 | 6 | from pytdbot import Client, types 7 | 8 | from src.utils import ApiData, shortener, APIResponse 9 | from ._media_utils import process_track_media, get_reply_markup 10 | 11 | 12 | @Client.on_updateNewInlineQuery() 13 | async def inline_search(c: Client, message: types.UpdateNewInlineQuery): 14 | query = message.query.strip() 15 | if not query: 16 | return None 17 | 18 | api = ApiData(query) 19 | if api.is_save_snap_url(): 20 | return await process_snap_inline(c, message, query) 21 | 22 | search = await api.get_info() if api.is_valid() else await api.search(limit="15") 23 | if isinstance(search, types.Error): 24 | await c.answerInlineQuery( 25 | message.id, 26 | results=[ 27 | types.InputInlineQueryResultArticle( 28 | id=str(uuid.uuid4()), 29 | title="❌ Search Failed", 30 | description=search.message or "Could not search Spotify.", 31 | ) 32 | ] 33 | ) 34 | return None 35 | 36 | results = [] 37 | for track in search.results: 38 | display_text = ( 39 | f"🎧 Track: {escape(track.name)}\n" 40 | f"👤 Artist: {escape(track.artist)}\n" 41 | f"📅 Year: {track.year}\n" 42 | f"⏱ Duration: {track.duration // 60}:{track.duration % 60:02d} mins\n" 43 | f"🔗 Platform: {track.platform.capitalize()}\n" 44 | f"{track.id}" 45 | ) 46 | 47 | parse = await c.parseTextEntities(display_text, types.TextParseModeHTML()) 48 | if isinstance(parse, types.Error): 49 | c.logger.warning(f"❌ Error parsing inline result for {track.name}: {parse.message}") 50 | continue 51 | 52 | reply_markup = get_reply_markup(track.name, track.artist) 53 | 54 | results.append( 55 | types.InputInlineQueryResultArticle( 56 | id=shortener.encode_url(track.url), 57 | title=f"{track.name} - {track.artist}", 58 | description=f"{track.name} by {track.artist} ({track.year})", 59 | thumbnail_url=track.cover, 60 | thumbnail_width=640, 61 | thumbnail_height=640, 62 | input_message_content=types.InputMessageText(parse), 63 | reply_markup=reply_markup, 64 | ) 65 | ) 66 | 67 | response = await c.answerInlineQuery(message.id, results=results) 68 | if isinstance(response, types.Error): 69 | c.logger.warning(f"❌ Inline response error: {response.message}") 70 | return None 71 | 72 | 73 | @Client.on_updateNewChosenInlineResult() 74 | async def inline_result(c: Client, message: types.UpdateNewChosenInlineResult): 75 | result_id = message.result_id 76 | inline_message_id = message.inline_message_id 77 | if not inline_message_id: 78 | return 79 | 80 | # Decode and validate URL 81 | url = shortener.decode_url(result_id) 82 | if not url: 83 | return 84 | 85 | api = ApiData(url) 86 | if api.is_save_snap_url(): 87 | return 88 | 89 | track = await api.get_track() 90 | if isinstance(track, types.Error): 91 | parsed_status = await c.parseTextEntities(f"❌ Failed to fetch track: {track.message or 'Unknown error'}", types.TextParseModeHTML()) 92 | await c.editInlineMessageText( 93 | inline_message_id=inline_message_id, 94 | input_message_content=types.InputMessageText(parsed_status) 95 | ) 96 | return 97 | 98 | # Process the track media 99 | audio, cover, status_text = await process_track_media( 100 | c, track, inline_message_id=inline_message_id 101 | ) 102 | 103 | if not audio: 104 | parsed_status = await c.parseTextEntities(status_text, types.TextParseModeHTML()) 105 | await c.editInlineMessageText( 106 | inline_message_id=inline_message_id, 107 | input_message_content=types.InputMessageText(parsed_status) 108 | ) 109 | return 110 | 111 | # Get reply markup 112 | reply_markup = get_reply_markup(track.name, track.artist) 113 | parsed_status = await c.parseTextEntities(status_text, types.TextParseModeHTML()) 114 | 115 | # Send the audio 116 | reply = await c.editInlineMessageMedia( 117 | inline_message_id=inline_message_id, 118 | input_message_content=types.InputMessageAudio( 119 | audio=audio, 120 | album_cover_thumbnail=types.InputThumbnail(types.InputFileLocal(cover)) if cover else None, 121 | title=track.name, 122 | performer=track.artist, 123 | duration=track.duration, 124 | caption=parsed_status, 125 | ), 126 | reply_markup=reply_markup, 127 | ) 128 | 129 | if isinstance(reply, types.Error): 130 | c.logger.error(f"❌ Failed to send audio file: {reply.message}") 131 | parsed_status = await c.parseTextEntities( 132 | f"❌ Failed to send the song. Please try again later.{reply.message}", 133 | types.TextParseModeHTML(), 134 | ) 135 | await c.editInlineMessageText( 136 | inline_message_id=inline_message_id, 137 | input_message_content=types.InputMessageText(parsed_status) 138 | ) 139 | 140 | 141 | async def process_snap_inline(c: Client, message: types.UpdateNewInlineQuery, query: str): 142 | api = ApiData(query) 143 | api_data: Union[APIResponse, types.Error, None] = await api.get_snap() 144 | 145 | if isinstance(api_data, types.Error) or not api_data: 146 | text = api_data.message.strip() or "An unknown error occurred." 147 | parse = await c.parseTextEntities(text, types.TextParseModeHTML()) 148 | await c.answerInlineQuery( 149 | inline_query_id=message.id, 150 | results=[ 151 | types.InputInlineQueryResultArticle( 152 | id=str(uuid.uuid4()), 153 | title="❌ Search Failed", 154 | description="Something went wrong.", 155 | input_message_content=types.InputMessageText(text=parse) 156 | ) 157 | ], 158 | cache_time=5 159 | ) 160 | return 161 | 162 | results = [] 163 | reply_markup = types.ReplyMarkupInlineKeyboard( 164 | [ 165 | [ 166 | types.InlineKeyboardButton( 167 | text="Search Again", 168 | type=types.InlineKeyboardButtonTypeSwitchInline( 169 | query=query, target_chat=types.TargetChatCurrent() 170 | ), 171 | ) 172 | ] 173 | ] 174 | ) 175 | 176 | results.extend( 177 | types.InputInlineQueryResultPhoto( 178 | id=str(uuid.uuid4()), 179 | photo_url=image_url, 180 | thumbnail_url=image_url, 181 | title=f"Photo {idx + 1}", 182 | description=f"Image result #{idx + 1}", 183 | input_message_content=types.InputMessagePhoto( 184 | photo=types.InputFileRemote(image_url) 185 | ), 186 | reply_markup=reply_markup, 187 | ) 188 | for idx, image_url in enumerate(api_data.image or []) 189 | if image_url and re.match("^https?://", image_url) 190 | ) 191 | for idx, video_data in enumerate(api_data.video or []): 192 | video_url = getattr(video_data, 'video', None) 193 | thumb_url = getattr(video_data, 'thumbnail', '') 194 | if not video_url or not re.match("^https?://", video_url): 195 | continue 196 | 197 | results.append( 198 | types.InputInlineQueryResultVideo( 199 | id=str(uuid.uuid4()), 200 | video_url=video_url, 201 | mime_type="video/mp4", 202 | thumbnail_url=thumb_url if thumb_url and re.match("^https?://", thumb_url) else "https://i.pinimg.com/736x/e2/c6/eb/e2c6eb0b48fc00f1304431bfbcacf50e.jpg", 203 | title=f"Video {idx + 1}", 204 | description=f"Video result #{idx + 1}", 205 | input_message_content=types.InputMessageVideo( 206 | video=types.InputFileRemote(video_url), 207 | thumbnail=types.InputThumbnail(types.InputFileRemote(thumb_url or video_url)) 208 | ), 209 | reply_markup=reply_markup 210 | ) 211 | ) 212 | 213 | if not results: 214 | parse = await c.parseTextEntities("No media found for this query", types.TextParseModeHTML()) 215 | results.append( 216 | types.InputInlineQueryResultArticle( 217 | id=str(uuid.uuid4()), 218 | title="No media found", 219 | description="Try a different search term", 220 | input_message_content=types.InputMessageText(text=parse) 221 | ) 222 | ) 223 | 224 | done = await c.answerInlineQuery( 225 | inline_query_id=message.id, 226 | results=results, 227 | cache_time=5, 228 | ) 229 | if isinstance(done, types.Error): 230 | c.logger.error(f"❌ Failed to answer inline query: {done.message}") 231 | await c.answerInlineQuery( 232 | inline_query_id=message.id, 233 | results=[ 234 | types.InputInlineQueryResultArticle( 235 | id=str(uuid.uuid4()), 236 | title="❌ Search Failed", 237 | description="Maybe Video size is too big.", 238 | input_message_content=types.InputMessageText(text=done.message) 239 | ) 240 | ], 241 | cache_time=5 242 | ) 243 | -------------------------------------------------------------------------------- /src/utils/_downloader.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import base64 3 | import binascii 4 | import logging 5 | import mimetypes 6 | import re 7 | import time 8 | import uuid 9 | import zipfile 10 | from pathlib import Path 11 | from typing import Optional, Tuple, Union 12 | from urllib.parse import urlparse 13 | 14 | from Crypto.Cipher import AES 15 | from mutagen.flac import Picture 16 | from mutagen.oggvorbis import OggVorbis 17 | from pytdbot import types 18 | 19 | from src import config 20 | from ._api import ApiData, HttpClient 21 | from ._dataclass import TrackInfo, PlatformTracks, MusicTrack 22 | 23 | # Constants 24 | CHUNK_SIZE = 1024 * 1024 # 1 MB 25 | DEFAULT_FILE_PERM = 0o644 26 | 27 | # Configure logging 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | class MissingKeyError(Exception): 32 | pass 33 | 34 | 35 | class InvalidHexKeyError(Exception): 36 | pass 37 | 38 | 39 | class Download: 40 | def __init__(self, track: Optional[TrackInfo] = None): 41 | self.track = track 42 | self.downloads_dir = config.DOWNLOAD_PATH 43 | self.downloads_dir.mkdir(parents=True, exist_ok=True, mode=0o755) 44 | filename = str(uuid.uuid4()) if track is None else self._sanitize_filename(track.name) 45 | self.output_file = self.downloads_dir / f"{filename}.ogg" 46 | 47 | async def process(self) -> Union[Tuple[str, Optional[str]], types.Error]: 48 | """Process the track download with optimized flow.""" 49 | try: 50 | if not self.track.cdnurl: 51 | return types.Error(message="Missing CDN URL") 52 | 53 | # Check for existing files first 54 | cover_path = self.downloads_dir / f"{self.track.tc}_cover.jpg" 55 | if self.output_file.exists(): 56 | logger.debug(f"Using cached file: {self.output_file}") 57 | return str(self.output_file), str(cover_path) if cover_path.exists() else None 58 | 59 | # Process based on platform 60 | if self.track.platform in ["youtube", "soundcloud"]: 61 | return await self.process_direct_dl() 62 | 63 | return await self.process_standard() 64 | 65 | except Exception as e: 66 | logger.error(f"Error processing track {self.track.tc}: {str(e)}", exc_info=True) 67 | return types.Error(message=f"Track processing failed: {str(e)}") 68 | 69 | async def process_direct_dl(self) -> Tuple[str, Optional[str]]: 70 | """Handle direct downloads with optimized flow.""" 71 | if re.match(r'^https:\/\/t\.me\/([a-zA-Z0-9_]{5,})\/(\d+)$', self.track.cdnurl): 72 | cover_path = await self.save_cover(self.track.cover) 73 | return self.track.cdnurl, cover_path 74 | 75 | file_path = await self.download_file(self.track.cdnurl, "") 76 | cover_path = await self.save_cover(self.track.cover) 77 | return file_path, cover_path 78 | 79 | async def process_standard(self) -> Tuple[str, Optional[str]]: 80 | """Optimized standard processing flow.""" 81 | start_time = time.monotonic() 82 | if not self.track.key: 83 | raise MissingKeyError("Missing CDN key") 84 | 85 | try: 86 | # Use temporary files in the same directory for atomic writes 87 | encrypted_file = self.downloads_dir / f"{uuid.uuid4()}.enc" 88 | decrypted_file = self.downloads_dir / f"{uuid.uuid4()}.tmp" 89 | 90 | try: 91 | await self.download_and_decrypt(encrypted_file, decrypted_file) 92 | await self.rebuild_ogg(decrypted_file) 93 | result = await self.vorb_repair_ogg(decrypted_file) 94 | self.output_file.chmod(DEFAULT_FILE_PERM) 95 | logger.info(f"Successfully processed {self.track.tc}: {result}") 96 | return result 97 | finally: 98 | encrypted_file.unlink(missing_ok=True) 99 | decrypted_file.unlink(missing_ok=True) 100 | finally: 101 | logger.info(f"Processed {self.track.tc} in {time.monotonic() - start_time:.2f}s") 102 | 103 | async def download_and_decrypt(self, encrypted_path: Path, decrypted_path: Path) -> None: 104 | client = await HttpClient.get_client() 105 | 106 | try: 107 | async with client.stream("GET", self.track.cdnurl) as response: 108 | response.raise_for_status() 109 | with encrypted_path.open("wb") as f: 110 | async for chunk in response.aiter_bytes(CHUNK_SIZE): 111 | f.write(chunk) 112 | 113 | decrypted_data = await asyncio.to_thread( 114 | self._decrypt_file, encrypted_path, self.track.key 115 | ) 116 | decrypted_path.write_bytes(decrypted_data) 117 | 118 | except Exception: 119 | encrypted_path.unlink(missing_ok=True) 120 | decrypted_path.unlink(missing_ok=True) 121 | raise 122 | 123 | @staticmethod 124 | def _decrypt_file(file_path: Path, hex_key: str) -> bytes: 125 | try: 126 | key = binascii.unhexlify(hex_key) 127 | audio_aes_iv = binascii.unhexlify("72e067fbddcbcf77ebe8bc643f630d93") 128 | cipher = AES.new(key, AES.MODE_CTR, nonce=b'', initial_value=audio_aes_iv) 129 | 130 | with file_path.open('rb') as f: 131 | return cipher.decrypt(f.read()) 132 | except binascii.Error as e: 133 | raise InvalidHexKeyError(f"Invalid hex key: {e}") from e 134 | 135 | @staticmethod 136 | async def rebuild_ogg(filename: Path) -> None: 137 | """Optimized OGG header rebuilding.""" 138 | patches = { 139 | 0: b'OggS', 140 | 6: b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 141 | 26: b'\x01\x1E\x01vorbis', 142 | 39: b'\x02', 143 | 40: b'\x44\xAC\x00\x00', 144 | 48: b'\x00\xE2\x04\x00', 145 | 56: b'\xB8\x01', 146 | 58: b'OggS', 147 | 62: b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00', 148 | } 149 | 150 | # Read-modify-write in one operation 151 | with filename.open('r+b') as file: 152 | for offset, data in patches.items(): 153 | file.seek(offset) 154 | file.write(data) 155 | 156 | async def vorb_repair_ogg(self, input_file: Path) -> Tuple[str, Optional[str]]: 157 | """Optimized metadata adding with error handling.""" 158 | cover_path = await self.save_cover(self.track.cover) 159 | try: 160 | await self._run_ffmpeg(input_file, self.output_file) 161 | await self._add_comments(self.output_file) 162 | except Exception as e: 163 | self.output_file.unlink(missing_ok=True) 164 | raise e 165 | 166 | return str(self.output_file), cover_path 167 | 168 | async def _run_ffmpeg(self, input_file: Path, output_file: Path) -> None: 169 | """Run ffmpeg with optimized parameters.""" 170 | cmd = [ 171 | 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', 172 | '-i', str(input_file), 173 | '-c', 'copy', 174 | '-metadata', f'lyrics={self.track.lyrics}', 175 | str(output_file) 176 | ] 177 | 178 | try: 179 | proc = await asyncio.create_subprocess_exec(*cmd, 180 | stdout=asyncio.subprocess.PIPE, 181 | stderr=asyncio.subprocess.PIPE, 182 | ) 183 | _, stderr = await proc.communicate() 184 | 185 | if proc.returncode != 0: 186 | raise Exception(f"ffmpeg failed: {stderr.decode()}") 187 | except FileNotFoundError as e: 188 | raise Exception("ffmpeg not found in PATH") from e 189 | 190 | async def _add_comments(self, output_file: Path) -> None: 191 | try: 192 | audio = OggVorbis(output_file) 193 | audio.clear() 194 | cover_path = await self.save_cover(self.track.cover) 195 | if cover_path: 196 | try: 197 | image_data = Path(cover_path).read_bytes() 198 | picture = Picture() 199 | picture.type = 3 # Cover (front) 200 | picture.mime = "image/jpeg" 201 | picture.desc = "Cover (front)" 202 | picture.width = 0 203 | picture.height = 0 204 | picture.depth = 0 205 | picture.data = image_data 206 | audio["METADATA_BLOCK_PICTURE"] = [base64.b64encode(picture.write()).decode("ascii")] 207 | except Exception as e: 208 | logger.warning(f"Failed to embed cover: {e}") 209 | 210 | # --- Text Metadata --- 211 | audio["ALBUM"] = [self.track.album] 212 | audio["ARTIST"] = [self.track.artist] 213 | audio["TITLE"] = [self.track.name] 214 | audio["GENRE"] = ["Spotify"] 215 | audio["DATE"] = [str(self.track.year)] 216 | audio["ALBUMARTIST"] = [self.track.artist] 217 | audio["YEAR"] = [str(self.track.year)] 218 | audio["LYRICS"] = [self.track.lyrics] 219 | audio["TRACKNUMBER"] = [str(self.track.tc)] 220 | audio["COMMENT"] = ["Via NoiNoi_bot | FallenProjects"] 221 | audio["PUBLISHER"] = [self.track.artist] 222 | audio["DURATION"] = [str(self.track.duration)] 223 | audio.save() 224 | except Exception as e: 225 | logger.error(f"Failed to add Vorbis comments: {e}", exc_info=True) 226 | 227 | async def download_file(self, url: str, file_name: str = "") -> str | types.Error: 228 | if not url: 229 | return types.Error(code=400, message="No URL provided") 230 | 231 | client = await HttpClient.get_client() 232 | self.downloads_dir.mkdir(parents=True, exist_ok=True) 233 | if not file_name: 234 | file_name = f"{self._sanitize_filename(self.track.name)}.mp4" 235 | try: 236 | async with client.stream("GET", url, follow_redirects=True) as response: 237 | if response.status_code != 200: 238 | return types.Error( 239 | code=response.status_code, 240 | message=f"Unexpected status code: {response.status_code}" 241 | ) 242 | 243 | cd = response.headers.get("content-disposition", "") 244 | if "filename=" in cd: 245 | file_name = cd.split("filename=")[-1].strip('"') 246 | 247 | elif not file_name: 248 | parsed_url = urlparse(url) 249 | if url_name := Path(parsed_url.path).name: 250 | file_name = url_name 251 | 252 | if not file_name or "." not in file_name: 253 | ext = "" 254 | if content_type := response.headers.get("content-type"): 255 | if guessed_ext := mimetypes.guess_extension( 256 | content_type.split(";")[0].strip() 257 | ): 258 | ext = guessed_ext 259 | file_name = file_name or f"file-{uuid.uuid4().hex}{ext}" 260 | 261 | file_name = Path(file_name) 262 | file_path = self.downloads_dir / file_name 263 | temp_path = file_path.with_suffix(f"{file_path.suffix}.part") 264 | 265 | if file_path.exists(): 266 | return str(file_path) 267 | 268 | with temp_path.open("wb") as f: 269 | async for chunk in response.aiter_bytes(CHUNK_SIZE): 270 | f.write(chunk) 271 | 272 | temp_path.rename(file_path) 273 | file_path.chmod(DEFAULT_FILE_PERM) 274 | return str(file_path) 275 | 276 | except Exception as e: 277 | try: 278 | temp_path.unlink(missing_ok=True) 279 | except NameError: 280 | pass 281 | return types.Error(code=500, message=f"Download failed: {str(e)}") 282 | 283 | @staticmethod 284 | def _sanitize_filename(name: str) -> str: 285 | """Sanitize filename for cross-platform safety.""" 286 | # Remove control/non-printable characters 287 | name = re.sub(r'[\x00-\x1f\x7f]+', '', name) 288 | # Replace invalid filename chars with underscore 289 | name = re.sub(r'[<>:"/\\|?*]', '_', name) 290 | # Allow only letters, numbers, spaces, dash, underscore, and dot 291 | name = re.sub(r'[^\w\s\-.]', '_', name) 292 | # Collapse multiple spaces/underscores into single space 293 | name = re.sub(r'[\s_]+', ' ', name).strip() 294 | return name 295 | 296 | async def save_cover(self, cover_url: Optional[str]) -> Optional[str]: 297 | if not cover_url: 298 | logger.warning("No cover URL provided") 299 | return None 300 | 301 | cover_path = self.downloads_dir / f"{self.track.tc}_cover.jpg" 302 | if cover_path.exists(): 303 | return str(cover_path) 304 | 305 | try: 306 | client = await HttpClient.get_client() 307 | response = await client.get(cover_url) 308 | if response.status_code != 200: 309 | logger.warning(f"Failed to download cover: HTTP {response.status_code}") 310 | return None 311 | 312 | cover_data = response.content 313 | cover_path.write_bytes(cover_data) 314 | cover_path.chmod(DEFAULT_FILE_PERM) 315 | return str(cover_path) 316 | except Exception as e: 317 | logger.warning(f"Failed to download cover: {e}") 318 | return None 319 | 320 | 321 | async def download_playlist_zip(playlist: PlatformTracks) -> Optional[str]: 322 | """Optimized playlist download with parallel processing.""" 323 | downloads_dir = config.DOWNLOAD_PATH 324 | zip_path = downloads_dir / f"playlist_{int(time.time())}.zip" 325 | temp_dir = downloads_dir / f"tmp_{uuid.uuid4()}" 326 | temp_dir.mkdir(parents=True, exist_ok=True) 327 | 328 | try: 329 | tasks = [ 330 | _process_playlist_track(music, temp_dir) 331 | for music in playlist.results 332 | ] 333 | audio_files = await asyncio.gather(*tasks, return_exceptions=True) 334 | audio_files = [f for f in audio_files if isinstance(f, Path)] 335 | 336 | if not audio_files: 337 | return None 338 | 339 | temp_zip = downloads_dir / f"tmp_{uuid.uuid4()}.zip" 340 | with zipfile.ZipFile(temp_zip, 'w', zipfile.ZIP_DEFLATED) as zipf: 341 | for file in audio_files: 342 | zipf.write(file, arcname=file.name) 343 | 344 | temp_zip.rename(zip_path) 345 | return str(zip_path) 346 | finally: 347 | for file in temp_dir.glob('*'): 348 | file.unlink(missing_ok=True) 349 | 350 | 351 | async def _process_playlist_track(music: MusicTrack, temp_dir: Path) -> Optional[Path]: 352 | """Process a single playlist track.""" 353 | try: 354 | track = await ApiData(music.url).get_track() 355 | if isinstance(track, types.Error): 356 | return None 357 | 358 | dl = Download(track) 359 | result = await dl.process() 360 | if isinstance(result, types.Error): 361 | return None 362 | 363 | audio_file, _ = result 364 | src_path = Path(audio_file) 365 | dest_path = temp_dir / src_path.name 366 | src_path.rename(dest_path) 367 | return dest_path 368 | except Exception as e: 369 | logger.warning(f"Failed to process track {music.url}: {e}") 370 | return None 371 | --------------------------------------------------------------------------------