├── .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 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
{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<@{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 == "{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 |
--------------------------------------------------------------------------------