├── revspotify ├── bot │ ├── __init__.py │ ├── utils.py │ ├── communications │ │ ├── message_handler.py │ │ └── locales │ │ │ └── en.json │ └── handlers.py ├── data │ └── .gitkeep ├── services │ ├── __init__.py │ ├── downloaders │ │ ├── youtube.py │ │ ├── soundcloud.py │ │ ├── downloader.py │ │ ├── spotify.py │ │ └── deezer.py │ ├── response.py │ ├── utils.py │ └── metadata.py ├── requirements.txt ├── .env.example ├── config │ └── config.py ├── logger.py └── main.py ├── docker-compose.yml ├── Dockerfile ├── README.md └── .gitignore /revspotify/bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /revspotify/data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /revspotify/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /revspotify/services/downloaders/youtube.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /revspotify/services/downloaders/soundcloud.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /revspotify/requirements.txt: -------------------------------------------------------------------------------- 1 | python-telegram-bot 2 | python-dotenv 3 | spotipy 4 | py-deezer 5 | unidecode 6 | eyed3 7 | beautifulsoup4 -------------------------------------------------------------------------------- /revspotify/.env.example: -------------------------------------------------------------------------------- 1 | TELEGRAM_BOT_TOKEN="1231231231:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" 2 | SPOTIFY_CLIENT_ID="0d0d0d0d0d0d0d0d0d0d0d0dd0d0d0d0" 3 | SPOTIFY_CLIENT_SECRET="3737373737373773737373373737" -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | revspotify: 4 | image: revspotify 5 | container_name: revspotify 6 | volumes: 7 | - ./revspotify:/app 8 | build: 9 | context: . 10 | dockerfile: Dockerfile -------------------------------------------------------------------------------- /revspotify/bot/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | def extract_spotify_id(url: str, type: str) -> str: 5 | pattern = rf"https?://(open\.)?spotify\.com/{type}/([a-zA-Z0-9]+)" 6 | match = re.search(pattern, url) 7 | return match.group(2) if match else None 8 | -------------------------------------------------------------------------------- /revspotify/config/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() 5 | 6 | 7 | class Config: 8 | TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") 9 | SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID") 10 | SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET") 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | COPY revspotify /app 4 | WORKDIR /app 5 | 6 | RUN apt-get update -y 7 | RUN pip3 install -U setuptools 8 | RUN apt-get install -y libssl-dev libffi-dev 9 | RUN apt-get install -y libxml2-dev libxslt1-dev zlib1g-dev 10 | RUN apt-get install -y ffmpeg 11 | RUN pip3 install -r requirements.txt 12 | 13 | CMD ["python3", "main.py"] -------------------------------------------------------------------------------- /revspotify/services/response.py: -------------------------------------------------------------------------------- 1 | class Response: 2 | def __init__(self, music_address=None, error=None, service=None): 3 | self.music_address = music_address 4 | self.error = error 5 | self.service = service 6 | 7 | def is_success(self): 8 | return self.error is None 9 | 10 | def is_error(self): 11 | return self.error is not None 12 | 13 | def __str__(self): 14 | return f"Response(music_address={self.music_address}, error={self.error}, service={self.service})" 15 | -------------------------------------------------------------------------------- /revspotify/bot/communications/message_handler.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | 5 | class MessageHandler: 6 | def __init__(self, locale="en"): 7 | self.locale = locale 8 | self.messages = self.load_messages() 9 | 10 | def load_messages(self): 11 | locale_file = os.path.join( 12 | os.path.dirname(__file__), f"locales/{self.locale}.json" 13 | ) 14 | with open(locale_file, "r", encoding="utf-8") as file: 15 | return json.load(file) 16 | 17 | def get_message(self, key, **kwargs): 18 | message = self.messages.get(key, "") 19 | return message.format(**kwargs) -------------------------------------------------------------------------------- /revspotify/services/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import unidecode 3 | 4 | 5 | class Singleton(type): 6 | _instances = {} 7 | 8 | def __call__(cls, *args, **kwargs): 9 | if cls not in cls._instances: 10 | cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) 11 | return cls._instances[cls] 12 | 13 | 14 | def clean_filename(filename): 15 | # Transliterate to closest ASCII representation 16 | ascii_str = unidecode.unidecode(filename) 17 | # Replace any remaining non-alphanumeric characters (excluding spaces) with underscores 18 | cleaned_filename = re.sub(r"[^a-zA-Z0-9 ._-]", "_", ascii_str) 19 | return cleaned_filename 20 | -------------------------------------------------------------------------------- /revspotify/bot/communications/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "welcome_message": "Welcome to the RevSpotify Bot!", 3 | "processing_message": "We have received your request and it's being processed.", 4 | "playlist_download_started": "Playlist {playlist_name} download started.", 5 | "album_download_started": "Album {album_name} download started.", 6 | "track_download_started": "Track {track_name} download started.", 7 | "download_completed": "Your download is complete.", 8 | "error_message": "An error occurred: {error}", 9 | "help_message": "Hello! I can help you download Spotify content.", 10 | "track_caption": "Track: {track_name}\nArtist: {artist_name}\nAlbum: {album_name}\nRelease Date: {release_date}", 11 | "album_caption": "Album: {album_name}\nArtist: {artist_name}\nRelease Date: {release_date}", 12 | "playlist_caption": "Playlist: {playlist_name}\nOwner: {owner_name}\nTotal Tracks: {total_tracks}", 13 | "artist_caption": "Artist: {artist_name}\nTotal Followers: {followers}\nGenres: {genres}", 14 | "task_done": "Everything is done!", 15 | "downloading_track": "Downloading {track_name}..." 16 | } -------------------------------------------------------------------------------- /revspotify/services/metadata.py: -------------------------------------------------------------------------------- 1 | import eyed3 2 | import requests 3 | from logger import Logger 4 | 5 | logger = Logger("metadata") 6 | 7 | def update_metadata(file_path, track_info): 8 | logger.info(f"Updating metadata for {file_path}") 9 | audiofile = eyed3.load(file_path) 10 | if audiofile.tag is None: 11 | audiofile.initTag() 12 | logger.debug("Initialized new tag for audio file") 13 | 14 | # Update text metadata 15 | audiofile.tag.title = track_info["track_name"] 16 | audiofile.tag.artist = track_info["artist_names"][0] 17 | audiofile.tag.album = track_info.get("album_name", "") 18 | audiofile.tag.release_date = track_info.get("release_date", "") 19 | logger.info(f"Updated text metadata for {file_path}") 20 | 21 | # Download cover photo 22 | cover_photo_url = track_info["cover_photo_url"] 23 | response = requests.get(cover_photo_url) 24 | if response.status_code == 200: 25 | cover_data = response.content 26 | # Remove all existing images 27 | for image in audiofile.tag.images: 28 | audiofile.tag.images.remove(image.description) 29 | # Set new cover photo 30 | audiofile.tag.images.set(3, cover_data, "image/jpeg", "Cover") 31 | logger.info(f"Updated cover photo for {file_path}") 32 | else: 33 | logger.warning(f"Failed to download cover photo from {cover_photo_url}") 34 | 35 | audiofile.tag.save() 36 | logger.info(f"Successfully saved metadata for {file_path}") -------------------------------------------------------------------------------- /revspotify/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.handlers import RotatingFileHandler 3 | import os 4 | 5 | 6 | class Logger: 7 | def __init__(self, service_name: str): 8 | self.service_name = service_name 9 | log_format = f"%(asctime)s - %(name)s - %(levelname)s - %(message)s" 10 | 11 | log_dir = "logs" 12 | log_file = f"{log_dir}/revspotify.log" 13 | 14 | # Ensure log directory exists 15 | os.makedirs(log_dir, exist_ok=True) 16 | 17 | # Set up a rotating file handler 18 | max_file_size = 10 * 1024 * 1024 # 10MB 19 | file_handler = RotatingFileHandler( 20 | log_file, maxBytes=max_file_size, backupCount=5 21 | ) 22 | file_handler.setFormatter(logging.Formatter(log_format)) 23 | 24 | # Set up a stream handler for console output 25 | console_handler = logging.StreamHandler() 26 | console_handler.setFormatter(logging.Formatter(log_format)) 27 | 28 | # Get the logger and set the level 29 | self._logger = logging.getLogger(self.service_name) 30 | self._logger.setLevel(logging.INFO) 31 | self._logger.addHandler(file_handler) 32 | self._logger.addHandler(console_handler) 33 | 34 | # Prevent the log messages from being duplicated in the python output 35 | self._logger.propagate = False 36 | 37 | def debug(self, message: str): 38 | self._logger.debug(message) 39 | 40 | def info(self, message: str): 41 | self._logger.info(message) 42 | 43 | def warning(self, message: str): 44 | self._logger.warning(message) 45 | 46 | def error(self, message: str): 47 | self._logger.error(message) 48 | -------------------------------------------------------------------------------- /revspotify/services/downloaders/downloader.py: -------------------------------------------------------------------------------- 1 | from services.downloaders.deezer import DeezerService 2 | from services.response import Response 3 | from services.metadata import update_metadata 4 | from logger import Logger 5 | 6 | # from services.soundcloud import download_from_soundcloud 7 | # from services.youtube import download_from_youtube 8 | 9 | logger = Logger("downloader") 10 | 11 | def download_track(track_info: dict): 12 | logger.info(f"Starting download for track: {track_info['track_name']} by {', '.join(track_info['artist_names'])}") 13 | deezer_service = DeezerService() 14 | deezer_query = f"{track_info['track_name']} {' '.join(track_info['artist_names'])}" 15 | logger.debug(f"Deezer query: {deezer_query}") 16 | track_response = deezer_service.search_and_download_track( 17 | query=deezer_query, duration_in_seconds=track_info["duration_ms"] // 1000 18 | ) 19 | if track_response.is_success(): 20 | file_path = track_response.music_address 21 | logger.info(f"Track downloaded successfully: {file_path}") 22 | update_metadata(file_path, track_info) 23 | logger.debug(f"Metadata updated for: {file_path}") 24 | return track_response 25 | 26 | logger.warning("Track download failed from Deezer, attempting other sources.") 27 | 28 | # download from soundcloud 29 | # logger.info("Attempting download from SoundCloud") 30 | # download_from_soundcloud(track_info) 31 | 32 | # download from youtube 33 | # logger.info("Attempting download from YouTube") 34 | # download_from_youtube(track_info) 35 | 36 | logger.warning(f"No matching track found in any service. for track: {track_info['track_name']} by {', '.join(track_info['artist_names'])}") 37 | return Response(error="No matching track found.", service="all") -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RevSpotify 2 | ## _A Telegram Bot that can download music from Spotify_ 3 | 4 | RevSpotify is a fast, useful telegram bot to have your Spotify music on Telegram. 5 | 6 | ![](https://cdn.dribbble.com/users/460659/screenshots/4837675/media/298bfc63139e23c19a9524c2510a2504.jpg) 7 | 8 | ## ✨ Features (till now) 9 | 10 | - Download tracks from Spotify 11 | - Download albums from Spotify 12 | - Download playlists from Spotify 13 | - Download artist's top tracks from Spotify 14 | 15 | ## ⚙️ Installation 16 | 17 | RevSpotify only and only requires [Docker](https://www.docker.com/) to run. 18 | 19 | Install Docker and start the bot, docker takes care of other dependencies. 20 | 21 | ```sh 22 | apt install docker-ce 23 | ``` 24 | 25 | Now clone the repo: 26 | ```sh 27 | git clone https://github.com/revisto/RevSpotify 28 | cd RevSpotify 29 | ``` 30 | 31 | Let's take care of .env files... 32 | 33 | ```sh 34 | cp revspotify/.env.example revspotify/.env 35 | ``` 36 | .env file contains your telegram bot token and spotify client id and secret. 37 | 38 | ``` 39 | TELEGRAM_BOT_TOKEN= 40 | SPOTIFY_CLIENT_ID= 41 | SPOTIFY_CLIENT_SECRET= 42 | ``` 43 | 44 | ## Docker 45 | 46 | Make sure that you have done all installation steps and made .env files. 47 | then, build it and run it. 48 | ```sh 49 | docker build -t revspotify . 50 | docker run -d revspotify 51 | ``` 52 | 53 | ## 🤝 Contributing 54 | 55 | Contributions, issues and feature requests are welcome.
56 | Feel free to check [issues page](https://github.com/revisto/RevSpotify/issues) if you want to contribute.

57 | 58 | 59 | ## Show your support 60 | 61 | Please ⭐️ this repository if this project helped you! 62 | 63 | 64 | ## 📝 License 65 | 66 | GNUv2 67 | 68 | **Free Software, Hell Yeah!** -------------------------------------------------------------------------------- /revspotify/main.py: -------------------------------------------------------------------------------- 1 | from telegram import Update 2 | from telegram.ext import Application, CommandHandler, MessageHandler, filters 3 | 4 | from bot.handlers import ( 5 | start, 6 | download_spotify_track, 7 | download_spotify_playlist, 8 | download_spotify_album, 9 | download_spotify_artist, 10 | ) 11 | from config.config import Config 12 | 13 | # Import the Logger class 14 | from logger import Logger 15 | 16 | 17 | def main() -> None: 18 | """Start the bot.""" 19 | # Instantiate the Logger 20 | logger = Logger("RevSpotify") 21 | 22 | logger.info("Starting the bot") 23 | 24 | # Create the Application and pass it your bot's token. 25 | application = Application.builder().token(Config.TELEGRAM_BOT_TOKEN).build() 26 | 27 | # on different commands - answer in Telegram 28 | application.add_handler(CommandHandler("start", start)) 29 | logger.info("Added handler for /start command") 30 | 31 | application.add_handler( 32 | MessageHandler( 33 | filters.Regex(r"https?://(open\.)?spotify\.com/track/([a-zA-Z0-9]+)"), 34 | download_spotify_track, 35 | ) 36 | ) 37 | logger.info("Added handler for Spotify track links") 38 | 39 | application.add_handler( 40 | MessageHandler( 41 | filters.Regex(r"https?://(open\.)?spotify\.com/album/([a-zA-Z0-9]+)"), 42 | download_spotify_album, 43 | ) 44 | ) 45 | logger.info("Added handler for Spotify album links") 46 | 47 | application.add_handler( 48 | MessageHandler( 49 | filters.Regex(r"https?://(open\.)?spotify\.com/playlist/([a-zA-Z0-9]+)"), 50 | download_spotify_playlist, 51 | ) 52 | ) 53 | logger.info("Added handler for Spotify playlist links") 54 | 55 | application.add_handler( 56 | MessageHandler( 57 | filters.Regex(r"https?://(open\.)?spotify\.com/artist/([a-zA-Z0-9]+)"), 58 | download_spotify_artist, 59 | ) 60 | ) 61 | logger.info("Added handler for Spotify artist links") 62 | 63 | # Run the bot until the user presses Ctrl-C 64 | try: 65 | application.run_polling(allowed_updates=Update.ALL_TYPES) 66 | except Exception as e: 67 | logger.error(f"An error occurred: {e}") 68 | finally: 69 | logger.info("Bot stopped") 70 | 71 | 72 | if __name__ == "__main__": 73 | main() 74 | -------------------------------------------------------------------------------- /revspotify/services/downloaders/spotify.py: -------------------------------------------------------------------------------- 1 | import spotipy 2 | from spotipy.oauth2 import SpotifyClientCredentials 3 | 4 | from config.config import Config 5 | from services.utils import Singleton 6 | from logger import Logger 7 | 8 | class SpotifyService(metaclass=Singleton): 9 | def __init__(self): 10 | self.logger = Logger("SpotifyService") 11 | self.logger.info("Initializing SpotifyService") 12 | self.client_id = Config.SPOTIFY_CLIENT_ID 13 | self.client_secret = Config.SPOTIFY_CLIENT_SECRET 14 | self.client_credentials_manager = SpotifyClientCredentials( 15 | client_id=self.client_id, client_secret=self.client_secret 16 | ) 17 | self.sp = spotipy.Spotify( 18 | client_credentials_manager=self.client_credentials_manager 19 | ) 20 | self.logger.info("SpotifyService initialized successfully") 21 | 22 | def search_track(self, query): 23 | self.logger.info(f"Searching for track: {query}") 24 | results = self.sp.search(q=query, type="track") 25 | self.logger.info(f"Search completed for query: {query}") 26 | return results 27 | 28 | def get_track_info(self, track_id): 29 | self.logger.info(f"Fetching track info for ID: {track_id}") 30 | track_info = self.sp.track(track_id) 31 | self.logger.info(f"Track info fetched for ID: {track_id}") 32 | return track_info 33 | 34 | def get_playlist_info(self, playlist_id): 35 | self.logger.info(f"Fetching playlist info for ID: {playlist_id}") 36 | playlist_info = self.sp.playlist(playlist_id) 37 | self.logger.info(f"Playlist info fetched for ID: {playlist_id}") 38 | return playlist_info 39 | 40 | def get_album_info(self, album_id): 41 | self.logger.info(f"Fetching album info for ID: {album_id}") 42 | album_info = self.sp.album(album_id) 43 | self.logger.info(f"Album info fetched for ID: {album_id}") 44 | return album_info 45 | 46 | def get_artist_info(self, artist_id): 47 | self.logger.info(f"Fetching artist info for ID: {artist_id}") 48 | artist_info = self.sp.artist(artist_id) 49 | artist_top_tracks = self.sp.artist_top_tracks(artist_id) 50 | artist_info["top_tracks"] = artist_top_tracks["tracks"] 51 | self.logger.info(f"Artist info fetched for ID: {artist_id}") 52 | return artist_info -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | .vscode/ -------------------------------------------------------------------------------- /revspotify/services/downloaders/deezer.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from bs4 import BeautifulSoup 3 | import json 4 | from pydeezer import Deezer 5 | from pydeezer.exceptions import LoginError 6 | from pydeezer.constants import track_formats 7 | import os 8 | 9 | from services.response import Response 10 | from services.utils import clean_filename 11 | from logger import Logger 12 | 13 | logger = Logger("DeezerService") 14 | 15 | USED_ARLS_FILE = "data/deezer_used_arls.json" 16 | DOWNLOAD_DIR = "data/downloads" 17 | 18 | 19 | class ARLManager: 20 | def __init__(self): 21 | logger.info("Initializing ARLManager") 22 | self.used_arls = self.load_used_arls() 23 | 24 | def load_used_arls(self): 25 | logger.info("Loading used ARLs") 26 | if os.path.exists(USED_ARLS_FILE): 27 | with open(USED_ARLS_FILE, "r") as file: 28 | return json.load(file) 29 | return [] 30 | 31 | def save_used_arls(self): 32 | logger.info("Saving used ARLs") 33 | with open(USED_ARLS_FILE, "w") as file: 34 | json.dump(self.used_arls, file) 35 | 36 | def gather_arls(self): 37 | logger.info("Gathering ARLs") 38 | url = "https://rentry.org/firehawk52#deezer-arls" 39 | arls = [] 40 | response = requests.get(url) 41 | if response.status_code == 200: 42 | soup = BeautifulSoup(response.text, "html.parser") 43 | elements = soup.find_all(class_="ntable") 44 | 45 | for element in elements: 46 | rows = element.find_all("tr") 47 | for row in rows[1:]: 48 | arl = [column.text for column in row.find_all("td")[1:-1]][-1] 49 | if arl not in self.used_arls: 50 | arls.append(arl) 51 | else: 52 | logger.error(f"Failed to gather ARLs, status code: {response.status_code}") 53 | 54 | return arls[::-1] 55 | 56 | def validate_arl(self, arl): 57 | logger.info(f"Validating ARL: {arl}") 58 | try: 59 | deezer = Deezer(arl=arl) 60 | deezer.user # Attempt to access user info to validate ARL 61 | return True 62 | except LoginError as e: 63 | logger.error(f"LoginError during ARL validation: {e}") 64 | return False 65 | 66 | def get_valid_arl(self): 67 | logger.info("Getting a valid ARL") 68 | arls = self.gather_arls() 69 | 70 | for arl in arls: 71 | if self.validate_arl(arl): 72 | logger.info(f"Found valid ARL: {arl}") 73 | return arl 74 | else: 75 | self.used_arls.append(arl) 76 | self.save_used_arls() 77 | 78 | logger.error("No valid ARL found") 79 | return None 80 | 81 | 82 | class DeezerService: 83 | def __init__(self): 84 | logger.info("Initializing DeezerService") 85 | self.arl_manager = ARLManager() 86 | self.used_arls = set() 87 | self.update_arl() 88 | 89 | def update_arl(self): 90 | logger.info("Updating ARL") 91 | arl = self.arl_manager.get_valid_arl() 92 | if arl is None: 93 | logger.error("No valid ARL found") 94 | return Response(error="No valid ARL found", service="deezer") 95 | self.used_arls.add(arl) 96 | self.arl = arl 97 | self.deezer = Deezer(arl=self.arl) 98 | logger.info("ARL updated successfully") 99 | 100 | def get_user_info(self): 101 | logger.info("Fetching user info") 102 | if not self.is_arl_valid(): 103 | logger.warning("ARL is not valid, updating ARL") 104 | self.update_arl() 105 | return self.deezer.user 106 | 107 | def search_tracks(self, query): 108 | logger.info(f"Searching tracks for query: {query}") 109 | return self.deezer.search_tracks(query) 110 | 111 | def search_and_download_track(self, query, duration_in_seconds): 112 | logger.info(f"Searching and downloading track for query: {query}, duration: {duration_in_seconds}") 113 | search_results = self.search_tracks(query) 114 | for track in search_results: 115 | if abs(track["duration"] - duration_in_seconds) <= 5: # Allowing a 5-second difference 116 | track_id = track["id"] 117 | download_response = self.download_track(track_id) 118 | return download_response 119 | logger.warning("No matching track found") 120 | return Response(error="No matching track found.", service="deezer") 121 | 122 | def download_track(self, track_id): 123 | logger.info(f"Downloading track with ID: {track_id}") 124 | try: 125 | track = self.deezer.get_track(track_id) 126 | if not track: 127 | logger.error("Track not found") 128 | return Response(error="Track not found", service="deezer") 129 | 130 | # Download the track 131 | filename = f"{track['info']['DATA']['ART_NAME']} - {track['info']['DATA']['SNG_TITLE']}.mp3" 132 | filename = clean_filename(filename) 133 | track["download"]( 134 | DOWNLOAD_DIR, 135 | quality=track_formats.MP3_320, 136 | with_lyrics=False, 137 | filename=filename, 138 | ) 139 | 140 | # check if the file exists 141 | if os.path.exists(f"{DOWNLOAD_DIR}/{filename}"): 142 | logger.info("Track downloaded successfully") 143 | return Response( 144 | music_address=f"{DOWNLOAD_DIR}/{filename}", service="deezer" 145 | ) 146 | logger.error("Failed to download track") 147 | return Response(error="Failed to download track", service="deezer") 148 | 149 | except Exception as e: 150 | logger.error(f"Error in download_track: {e}") 151 | return Response(error=f"Error in download_track: {e}", service="deezer") -------------------------------------------------------------------------------- /revspotify/bot/handlers.py: -------------------------------------------------------------------------------- 1 | from telegram import Update, InputFile 2 | from telegram.ext import ContextTypes 3 | from io import BytesIO 4 | import requests 5 | import os 6 | 7 | from bot.communications.message_handler import MessageHandler 8 | from bot.utils import extract_spotify_id 9 | from services.downloaders.spotify import SpotifyService 10 | from services.downloaders.downloader import download_track 11 | from logger import Logger 12 | 13 | 14 | logger = Logger("handlers") 15 | 16 | 17 | async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: 18 | logger.info(f"Received /start command from {update.effective_user.username}") 19 | await update.message.reply_text(MessageHandler().get_message("welcome_message")) 20 | 21 | 22 | async def handle_track( 23 | update: Update, 24 | context: ContextTypes.DEFAULT_TYPE, 25 | track_info: dict, 26 | ) -> None: 27 | logger.info( 28 | f"Downloading track: {track_info['name']} by {track_info['artists'][0]['name']}" 29 | ) 30 | 31 | track_info_summery = { 32 | "track_name": track_info["name"], 33 | "artist_names": [artist["name"] for artist in track_info["artists"]], 34 | "duration_ms": track_info["duration_ms"], 35 | "album_name": track_info["album"]["name"], 36 | "release_date": track_info["album"]["release_date"], 37 | "cover_photo_url": track_info["album"]["images"][0]["url"], 38 | } 39 | # print all album cover 40 | for image in track_info["album"]["images"]: 41 | print(image["url"]) 42 | logger.info("Downloading track...") 43 | track_response = download_track(track_info_summery) 44 | if track_response.is_success(): 45 | logger.info("Fetching track thumbnail") 46 | thumbnail_url = track_info["album"]["images"][0]["url"] 47 | response = requests.get(thumbnail_url) 48 | image = BytesIO(response.content) 49 | 50 | logger.info("Sending audio file") 51 | await update.message.reply_audio(audio=track_response.music_address, thumbnail=InputFile(image, filename="thumbnail.jpg")) 52 | 53 | logger.info("Deleting audio file") 54 | os.remove(track_response.music_address) 55 | else: 56 | logger.error(f"Error downloading track: {track_response.error}") 57 | await update.message.reply_text(track_response.error) 58 | 59 | 60 | async def download_spotify_track( 61 | update: Update, context: ContextTypes.DEFAULT_TYPE 62 | ) -> None: 63 | logger.info( 64 | f"Received Spotify track link: {update.message.text} from {update.effective_user.username}" 65 | ) 66 | track_id = extract_spotify_id(update.message.text, "track") 67 | track_info = SpotifyService().get_track_info(track_id) 68 | # send track cover once 69 | logger.info("Sending track cover photo") 70 | cover_photo_url = track_info["album"]["images"][0]["url"] 71 | caption = MessageHandler().get_message( 72 | "track_caption", 73 | track_name=track_info["name"], 74 | artist_name=", ".join([artist["name"] for artist in track_info["artists"]]), 75 | album_name=track_info["album"]["name"], 76 | release_date=track_info["album"]["release_date"], 77 | ) 78 | await update.message.reply_photo(photo=cover_photo_url, caption=caption) 79 | waiting_message = await update.message.reply_text( 80 | MessageHandler().get_message("downloading_track", track_name=track_info["name"]) 81 | ) 82 | await handle_track(update, context, track_info) 83 | await waiting_message.delete() 84 | await update.message.reply_text(MessageHandler().get_message("task_done")) 85 | logger.info("Track download completed") 86 | 87 | 88 | async def download_spotify_playlist( 89 | update: Update, context: ContextTypes.DEFAULT_TYPE 90 | ) -> None: 91 | logger.info( 92 | f"Received Spotify playlist link: {update.message.text} from {update.effective_user.username}" 93 | ) 94 | playlist_id = extract_spotify_id(update.message.text, "playlist") 95 | playlist_info = SpotifyService().get_playlist_info(playlist_id) 96 | # send playlist cover once 97 | logger.info("Sending playlist cover photo") 98 | cover_photo_url = playlist_info["images"][0]["url"] 99 | caption = MessageHandler().get_message( 100 | "playlist_caption", 101 | playlist_name=playlist_info["name"], 102 | owner_name=playlist_info["owner"]["display_name"], 103 | total_tracks=playlist_info["tracks"]["total"], 104 | ) 105 | await update.message.reply_photo(photo=cover_photo_url, caption=caption) 106 | for track in playlist_info["tracks"]["items"]: 107 | if track["track"]["is_local"] or track["track"]["track"] is not True: 108 | logger.info("Skipping local track") 109 | continue 110 | track_info = SpotifyService().get_track_info(track["track"]["id"]) 111 | waiting_message = await update.message.reply_text( 112 | MessageHandler().get_message( 113 | "downloading_track", track_name=track_info["name"] 114 | ) 115 | ) 116 | await handle_track(update, context, track_info) 117 | await waiting_message.delete() 118 | 119 | await update.message.reply_text(MessageHandler().get_message("task_done")) 120 | logger.info("Playlist download completed") 121 | 122 | 123 | async def download_spotify_album( 124 | update: Update, context: ContextTypes.DEFAULT_TYPE 125 | ) -> None: 126 | logger.info( 127 | f"Received Spotify album link: {update.message.text} from {update.effective_user.username}" 128 | ) 129 | album_id = extract_spotify_id(update.message.text, "album") 130 | album_info = SpotifyService().get_album_info(album_id) 131 | # Send album cover once 132 | logger.info("Sending album cover photo") 133 | cover_photo_url = album_info["images"][0]["url"] 134 | caption = MessageHandler().get_message( 135 | "album_caption", 136 | album_name=album_info["name"], 137 | artist_name=", ".join([artist["name"] for artist in track_info["artists"]]), 138 | release_date=album_info["release_date"], 139 | ) 140 | await update.message.reply_photo(photo=cover_photo_url, caption=caption) 141 | for track in album_info["tracks"]["items"]: 142 | track_info = SpotifyService().get_track_info(track["id"]) 143 | waiting_message = await update.message.reply_text( 144 | MessageHandler().get_message( 145 | "downloading_track", track_name=track_info["name"] 146 | ) 147 | ) 148 | await handle_track(update, context, track_info) 149 | await waiting_message.delete() 150 | await update.message.reply_text(MessageHandler().get_message("task_done")) 151 | logger.info("Album download completed") 152 | 153 | 154 | async def download_spotify_artist( 155 | update: Update, context: ContextTypes.DEFAULT_TYPE 156 | ) -> None: 157 | logger.info( 158 | f"Received Spotify artist link: {update.message.text} from {update.effective_user.username}" 159 | ) 160 | artist_id = extract_spotify_id(update.message.text, "artist") 161 | artist_info = SpotifyService().get_artist_info(artist_id) 162 | # Send artist cover once 163 | logger.info("Sending artist cover photo") 164 | cover_photo_url = artist_info["images"][0]["url"] 165 | followers = artist_info["followers"]["total"] 166 | followers = "{:,}".format(followers) 167 | caption = MessageHandler().get_message( 168 | "artist_caption", 169 | artist_name=artist_info["name"], 170 | followers=followers, 171 | genres=", ".join(artist_info["genres"][:3]), 172 | ) 173 | await update.message.reply_photo(photo=cover_photo_url, caption=caption) 174 | for track in artist_info["top_tracks"]: 175 | track_info = SpotifyService().get_track_info(track["id"]) 176 | waiting_message = await update.message.reply_text( 177 | MessageHandler().get_message( 178 | "downloading_track", track_name=track_info["name"] 179 | ) 180 | ) 181 | await handle_track(update, context, track_info) 182 | await waiting_message.delete() 183 | await update.message.reply_text(MessageHandler().get_message("task_done")) 184 | logger.info("Artist download completed") 185 | --------------------------------------------------------------------------------