├── .env ├── .gitignore ├── README.md ├── SETUP.md ├── docs └── .gitkeep ├── requirements.txt ├── setup.py ├── tests ├── .gitkeep └── test_utils.py └── ytspdl ├── inspect ├── README.md └── generate_and_inspect.py ├── main.py ├── models ├── __init__.py ├── resulttype.py ├── servicedata.py ├── servicetype.py └── song.py ├── questions.py ├── services ├── __init__.py ├── musicservice.py ├── spotify.py └── ytmusic.py └── utils ├── __init__.py ├── downloader.py ├── helpers.py └── metadata.py /.env: -------------------------------------------------------------------------------- 1 | SPOTIPY_CLIENT_ID="your_spotify_client_id" 2 | SPOTIPY_CLIENT_SECRET="your_spotify_client_secret" 3 | SPOTIPY_REDIRECT_URI="http://localhost:8888/callback" 4 | SPOTIPY_SCOPE="user-library-read" -------------------------------------------------------------------------------- /.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/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # VScode 163 | .vscode -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YoutubeSpotifyDL 2 | 3 | Youtube and Spotify downloader 4 | 5 | ## Example 6 | 7 | https://user-images.githubusercontent.com/82235530/166157330-1ff1f26e-5feb-4b45-b232-b8e0f41a3520.mp4 8 | 9 | 10 | ## How to run 11 | #### 1. Create a Spotify App. 12 |
13 | [See how to create and setup a spotify app](./SETUP.md)
14 | 
15 | 16 | #### 2. Set Environment Variavles 17 |
18 | Edit the [.env](./.env) file and set the credentials.
19 | Set the *SPOTIPY_CLIENT_ID* AND *SPOTIPY_CLIENT_SECRET* values.
20 | 
21 | 22 | #### 3. Clone the project 23 | ```cmd 24 | git clone https://github.com/piyx/YoutubeSpotifyDL.git 25 | ``` 26 | 27 | #### 4. Navigate to the project directory. 28 | ```cmd 29 | cd YoutubeSpotifyDL 30 | ``` 31 | 32 | #### 5. Install Dependencies 33 | ```cmd 34 | pip install -r requirements.txt 35 | ``` 36 | 37 | #### 6. Install the package locally. 38 | ```cmd 39 | pip install -e . 40 | ``` 41 | 42 | #### 7. Run main.py inside ytspdl folder 43 | ```cmd 44 | python ytspdl/main.py 45 | ``` 46 | -------------------------------------------------------------------------------- /SETUP.md: -------------------------------------------------------------------------------- 1 | ## Setup 2 | 3 | 1.Create an app: https://developer.spotify.com/dashboard/applications 4 | 5 | ![setup](https://user-images.githubusercontent.com/82235530/166215909-d71bea89-1129-4405-9023-1bf83c2cf76f.png) 6 | 7 | 8 | 2.Copy the Client id and client secret and paste the values in [.env file](./.env) (as mentioned in Step 2) 9 | 10 | ![copy](https://user-images.githubusercontent.com/82235530/166215937-1b6a2a5a-ab48-48e7-a586-875bd4b387d1.png) 11 | 12 | 3.Set redirect uri to http://localhost:8888/callback 13 | 14 | ![redirecturi](https://user-images.githubusercontent.com/82235530/166215968-7cf5676f-827a-40e4-bc8f-170e14e81782.png) 15 | -------------------------------------------------------------------------------- /docs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piyx/YoutubeSpotifyDL/d9a2356dff8cae597d5c938594d9d59842687d11/docs/.gitkeep -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | spotipy==2.19.0 2 | ytmusicapi==0.21.0 3 | python-dotenv==0.20.0 4 | yt-dlp==2022.4.8 5 | requests==2.27.1 6 | aiohttp==3.8.1 7 | mutagen==1.45.1 8 | yt-dlp==2022.4.8 9 | inquirerpy==0.3.3 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__=="__main__": 4 | setup() -------------------------------------------------------------------------------- /tests/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piyx/YoutubeSpotifyDL/d9a2356dff8cae597d5c938594d9d59842687d11/tests/.gitkeep -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from ytspdl.utils import extract_playlist_id 2 | from ytspdl.utils import fetch_youtube_video_id 3 | 4 | 5 | def test_extract_playlist_id(): 6 | playlist_url1 = "https://youtube.com/playlist?list=PLQwVIlKxHM6qv-o99iX9R85og7IzF9YS_" 7 | playlist_url2 = "https://youtube.com/playlist?somethingrandom" 8 | 9 | assert extract_playlist_id(playlist_url1) == "PLQwVIlKxHM6qv-o99iX9R85og7IzF9YS_" 10 | assert extract_playlist_id(playlist_url2) is None 11 | 12 | 13 | def test_fetch_youtube_video_url(): 14 | song_name1 = "flor hold on" 15 | song_name2 = "" 16 | 17 | assert fetch_youtube_video_id(song_name1) is not None 18 | assert fetch_youtube_video_id(song_name2) is not None 19 | 20 | 21 | if __name__=="__main__": 22 | test_extract_playlist_id() 23 | test_fetch_youtube_video_url() -------------------------------------------------------------------------------- /ytspdl/inspect/README.md: -------------------------------------------------------------------------------- 1 | # RESPONSE STRUCTURE 2 | 3 | ## SPOTIFY 4 | 5 | ### PLAYLIST RESPONSE 6 | 7 | ```json 8 | { 9 | "items": [ 10 | { 11 | "track": { 12 | "album": { 13 | "name": "album_name" 14 | "images": [ 15 | {"url": "song_thumbnail_url"} 16 | ], 17 | }, 18 | "artists": [ 19 | {"name": "artist_name"} 20 | ], 21 | "name": "song_name" 22 | } 23 | } 24 | ] 25 | } 26 | ``` 27 | 28 | ### LIKED SONGS RESPONSE 29 | 30 | ```json 31 | { 32 | "items": [ 33 | { 34 | "track": { 35 | "album": { 36 | "name": "album_name" 37 | "images": [ 38 | {"url": "song_thumbnail_url"} 39 | ], 40 | }, 41 | "artists": [ 42 | {"name": "artist_name"} 43 | ], 44 | "name": "song_name" 45 | } 46 | } 47 | ] 48 | } 49 | ``` 50 | 51 | ### INDIVIDUAL SONG RESPONSE 52 | 53 | ```json 54 | { 55 | "tracks": { 56 | "items": [ 57 | { 58 | "album": { 59 | "name": "album_name" 60 | "images": [ 61 | {"url": "song_thumbnail_url"} 62 | ], 63 | }, 64 | "artists": [ 65 | {"name": "artist_name"} 66 | ], 67 | "name": "song_name" 68 | } 69 | ] 70 | } 71 | } 72 | ``` 73 | 74 | 75 | ## YOUTUBE 76 | 77 | ### PLAYLIST SONG RESPONSE 78 | ```json 79 | { 80 | "tracks": [ 81 | { 82 | "title": "song_name", 83 | "album": { 84 | "name": "album_name" 85 | }, 86 | "artists": [ 87 | { 88 | "name": "artist_name" 89 | } 90 | ], 91 | "thumbnails": [ 92 | { 93 | "url": "song_thumbnail_url" 94 | } 95 | ] 96 | } 97 | ] 98 | } 99 | ``` 100 | 101 | ### INDIVIDUAL SONG RESPONSE 102 | ```json 103 | [ 104 | { 105 | "videoId": "youtube_song_video_id", 106 | "title": "song_name", 107 | "album": { 108 | "name": "album_name" 109 | }, 110 | "artists": [ 111 | { 112 | "name": "artist_name" 113 | } 114 | ], 115 | "thumbnails": [ 116 | { 117 | "url": "song_thumbnail_url" 118 | } 119 | ] 120 | } 121 | ] 122 | ``` -------------------------------------------------------------------------------- /ytspdl/inspect/generate_and_inspect.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fetch data from library/api and store it in json files and 3 | inspect the results in json files for the data structure. 4 | """ 5 | 6 | import json 7 | import os 8 | 9 | import dotenv 10 | import spotipy 11 | import ytmusicapi 12 | from spotipy.oauth2 import SpotifyOAuth 13 | 14 | from ytspdl.models import MusicServiceData 15 | from ytspdl.utils import extract_playlist_id 16 | 17 | 18 | # Load environment variables 19 | dotenv.load_dotenv(".env") 20 | 21 | 22 | # Sample album and playlist url 23 | spotify_json_data_folder = "spotify-json-data" 24 | sample_spotify_playlist_url = "https://open.spotify.com/playlist/37i9dQZF1E8UXBoz02kGID" 25 | sample_spotify_song_query = "flor hold on" 26 | 27 | youtube_json_data_folder = "youtube-json-data" 28 | sample_youtube_playlist_url = "https://youtube.com/playlist?list=PLQwVIlKxHM6qv-o99iX9R85og7IzF9YS_" 29 | sample_youtube_song_query = "flor hold on" 30 | 31 | 32 | def generate_spotify_data() -> MusicServiceData: 33 | spotify_oauth = SpotifyOAuth(scope=os.getenv("SPOTIPY_SCOPE")) 34 | spotify = spotipy.Spotify(oauth_manager=spotify_oauth) 35 | 36 | return MusicServiceData( 37 | liked_songs=spotify.current_user_saved_tracks(), 38 | playlist_songs=spotify.playlist(sample_spotify_playlist_url), 39 | individual_song=spotify.search(sample_spotify_song_query), 40 | ) 41 | 42 | 43 | def generate_youtube_data() -> MusicServiceData: 44 | youtube = ytmusicapi.YTMusic() 45 | playlist_id = extract_playlist_id(sample_youtube_playlist_url) 46 | 47 | return MusicServiceData( 48 | liked_songs=[], 49 | playlist_songs=youtube.get_playlist(playlist_id), 50 | individual_song=youtube.search(sample_youtube_song_query), 51 | ) 52 | 53 | 54 | def write_to_json(directory: str, data_source: callable) -> None: 55 | liked_songs, playlist_songs, individual_song = data_source() 56 | os.makedirs(directory, exist_ok=True) 57 | 58 | with ( 59 | open(f"./{directory}/liked_songs.json", "w") as liked_songs_json, 60 | open(f"./{directory}/playlist_songs.json", "w") as playlist_songs_json, 61 | open(f"./{directory}/individual_song.json", "w") as individual_song_json, 62 | ): 63 | json.dump(liked_songs, liked_songs_json) 64 | json.dump(playlist_songs, playlist_songs_json) 65 | json.dump(individual_song, individual_song_json) 66 | 67 | 68 | if __name__ == "__main__": 69 | write_to_json(directory=spotify_json_data_folder, data_source=generate_spotify_data) 70 | write_to_json(directory=youtube_json_data_folder, data_source=generate_youtube_data) -------------------------------------------------------------------------------- /ytspdl/main.py: -------------------------------------------------------------------------------- 1 | from threading import Thread 2 | import os 3 | 4 | import InquirerPy 5 | import spotipy 6 | from spotipy.oauth2 import SpotifyOAuth 7 | from ytmusicapi import YTMusic 8 | import dotenv 9 | 10 | from ytspdl.services import Youtube 11 | from ytspdl.services import Spotify 12 | from ytspdl.questions import questions 13 | from ytspdl.models import ResultType 14 | from ytspdl.models import ServiceType 15 | from ytspdl.utils import SongDownloader 16 | 17 | 18 | # Load environment variables 19 | dotenv.load_dotenv(".env") 20 | 21 | 22 | def main(): 23 | user_inputs = InquirerPy.prompt(questions=questions) 24 | music_service = None 25 | 26 | match user_inputs["music_service"]: 27 | case ServiceType.SPOTIFY: 28 | client = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=os.getenv("SPOTIPY_SCOPE"))) 29 | music_service = Spotify(client=client) 30 | case ServiceType.YOUTUBE: 31 | client = YTMusic() 32 | music_service = Youtube(client=client) 33 | 34 | match user_inputs["download_choice"]: 35 | case ResultType.PLAYLIST: 36 | playlist_url = user_inputs["playlist_url"] 37 | limit = int(user_inputs["limit"]) if user_inputs["limit"] else None 38 | songs = music_service.get_playlist_songs(playlist_url=playlist_url, limit=limit) 39 | case ResultType.LIKED: 40 | limit = int(user_inputs["limit"]) if user_inputs["limit"] else None 41 | songs = music_service.get_liked_songs(limit=limit) 42 | case ResultType.INDIVIDUAL: 43 | song_name = user_inputs["song_name"] 44 | songs = music_service.get_song(song_name=song_name) 45 | 46 | 47 | threads = [] 48 | download_location = user_inputs["download_location"] 49 | 50 | print("Press ctrl+c to stop.") 51 | for song in songs: 52 | song_downloader = SongDownloader(song=song, download_location=download_location) 53 | thread = Thread(target=song_downloader.download, daemon=True) 54 | threads.append(thread) 55 | thread.start() 56 | 57 | for thread in threads: 58 | thread.join() 59 | 60 | 61 | if __name__=="__main__": 62 | main() -------------------------------------------------------------------------------- /ytspdl/models/__init__.py: -------------------------------------------------------------------------------- 1 | from ytspdl.models.servicedata import MusicServiceData 2 | from ytspdl.models.song import Song 3 | from ytspdl.models.resulttype import ResultType 4 | from ytspdl.models.servicetype import ServiceType -------------------------------------------------------------------------------- /ytspdl/models/resulttype.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class ResultType(enum.Enum): 5 | PLAYLIST = 1 6 | LIKED = 2 7 | INDIVIDUAL = 3 -------------------------------------------------------------------------------- /ytspdl/models/servicedata.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | 4 | JsonType = list | dict 5 | 6 | 7 | class MusicServiceData(NamedTuple): 8 | """Data class for music service 9 | """ 10 | liked_songs: JsonType 11 | playlist_songs: JsonType 12 | individual_song: JsonType -------------------------------------------------------------------------------- /ytspdl/models/servicetype.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class ServiceType(enum.Enum): 5 | SPOTIFY = 1 6 | YOUTUBE = 2 -------------------------------------------------------------------------------- /ytspdl/models/song.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Song: 6 | """Song entity 7 | 8 | Attributes: 9 | title: The name of the song 10 | artist: The artist of the song 11 | album: The album of the song 12 | imgurl: The image/thumbnail url for the song 13 | vidurl: The youtube video url for the song 14 | """ 15 | title: str = None 16 | artist: str = None 17 | album: str = None 18 | imgurl: str = None 19 | vidurl: str = None -------------------------------------------------------------------------------- /ytspdl/questions.py: -------------------------------------------------------------------------------- 1 | from InquirerPy.validator import EmptyInputValidator 2 | from InquirerPy.validator import PathValidator 3 | from InquirerPy.base.control import Choice 4 | 5 | from ytspdl.models import ResultType 6 | from ytspdl.models import ServiceType 7 | 8 | questions = [ 9 | { 10 | "name": "music_service", 11 | "message": "Download From?", 12 | "type": "list", 13 | "choices": [ 14 | Choice(name="Spotify", value=ServiceType.SPOTIFY), 15 | Choice(name="Youtube", value=ServiceType.YOUTUBE), 16 | ] 17 | }, 18 | { 19 | "name": "download_choice", 20 | "message": "What do you want to do?", 21 | "type": "list", 22 | "choices": [ 23 | Choice(name="Download a playlist", value=ResultType.PLAYLIST), 24 | Choice(name="Download liked songs", value=ResultType.LIKED), 25 | Choice(name="Download a particular song", value=ResultType.INDIVIDUAL), 26 | ] 27 | }, 28 | { 29 | "name": "playlist_url", 30 | "type": "input", 31 | "message": "Enter playlist url", 32 | "validate": EmptyInputValidator("Playlist url cannot be empty"), 33 | "when": lambda x: x["download_choice"] == ResultType.PLAYLIST 34 | }, 35 | { 36 | "name": "song_name", 37 | "type": "input", 38 | "message": "Enter song name (Title and Artist):", 39 | "validate": EmptyInputValidator("Song name cannot be empty"), 40 | "when": lambda x: x["download_choice"] == ResultType.INDIVIDUAL 41 | }, 42 | { 43 | "name": "limit", 44 | "message": "How many songs do you want to download? (Leave blank to download all):", 45 | "type": "number", 46 | "default": None, 47 | "min_allowed": 1, 48 | "when": lambda x: x["download_choice"] in [ResultType.PLAYLIST, ResultType.LIKED] 49 | }, 50 | { 51 | "name": "download_location", 52 | "type": "filepath", 53 | "message": "Enter path where you want songs to be downloaded:", 54 | "only_directories": True, 55 | "validate": PathValidator() 56 | } 57 | ] -------------------------------------------------------------------------------- /ytspdl/services/__init__.py: -------------------------------------------------------------------------------- 1 | from ytspdl.services.spotify import Spotify 2 | from ytspdl.services.ytmusic import Youtube 3 | from ytspdl.services.musicservice import MusicService -------------------------------------------------------------------------------- /ytspdl/services/musicservice.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | from abc import ABC 3 | 4 | 5 | from ytspdl.models import Song 6 | 7 | 8 | class MusicService(ABC): 9 | @abstractmethod 10 | def get_playlist_songs(self, playlist_url: str, limit: int = None) -> list[Song]: 11 | ... 12 | 13 | @abstractmethod 14 | def get_liked_songs(self, limit: int = None) -> list[Song]: 15 | ... 16 | 17 | @abstractmethod 18 | def get_song(self, song_name: str) -> Song: 19 | ... 20 | -------------------------------------------------------------------------------- /ytspdl/services/spotify.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import functools 3 | 4 | import spotipy 5 | 6 | from ytspdl.services.musicservice import MusicService 7 | from ytspdl.models import Song 8 | from ytspdl.models import ResultType 9 | 10 | 11 | @dataclass 12 | class Spotify(MusicService): 13 | client: spotipy.Spotify 14 | 15 | def _parse_track(self, track: dict) -> Song: 16 | return Song( 17 | title=track["name"], 18 | album=track["album"]["name"], 19 | artist=track["artists"][0]["name"], 20 | imgurl=track["album"]["images"][0]["url"] 21 | ) 22 | 23 | def _parse_songs(self, results: dict, result_type: ResultType) -> list[Song]: 24 | # Spotify has inconsitent response structure 25 | match result_type: 26 | case ResultType.PLAYLIST | ResultType.LIKED: 27 | results = [item["track"] for item in results["items"]] 28 | case ResultType.INDIVIDUAL: 29 | results = results["tracks"]["items"] 30 | 31 | return [ 32 | self._parse_track(track) 33 | for track in results 34 | if track is not None 35 | ] 36 | 37 | def _fetch_songs(self, api: callable, result_type: ResultType, limit: int = None) -> list[Song]: 38 | if limit is None: 39 | limit = 1000 40 | 41 | offset = 0 42 | songs_fetched = 0 43 | songs = [] 44 | fetched_all = False 45 | 46 | while not fetched_all and songs_fetched < limit: 47 | songs_to_fetch = min(limit-songs_fetched, 50) 48 | results = api(offset=offset, limit=songs_to_fetch) 49 | 50 | next_songs = self._parse_songs(results=results, result_type=result_type) 51 | songs.extend(next_songs) 52 | 53 | songs_fetched += len(next_songs) 54 | offset += len(next_songs) 55 | 56 | if not next_songs: 57 | fetched_all = True 58 | 59 | return songs 60 | 61 | def get_playlist_songs(self, playlist_url: str, limit: int = None) -> list[Song]: 62 | api_source = functools.partial(self.client.playlist_items, playlist_id=playlist_url) 63 | return self._fetch_songs(api=api_source, result_type=ResultType.PLAYLIST, limit=limit) 64 | 65 | def get_liked_songs(self, limit: int = None) -> list[Song]: 66 | api_source = self.client.current_user_saved_tracks 67 | return self._fetch_songs(api=api_source, result_type=ResultType.LIKED, limit=limit) 68 | 69 | def get_song(self, song_name: str) -> list[Song]: 70 | api_source = functools.partial(self.client.search, q=song_name) 71 | return self._fetch_songs(api=api_source, result_type=ResultType.INDIVIDUAL, limit=1) -------------------------------------------------------------------------------- /ytspdl/services/ytmusic.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import functools 3 | 4 | from ytmusicapi import YTMusic 5 | 6 | from ytspdl.services.musicservice import MusicService 7 | from ytspdl.models import Song 8 | from ytspdl.models import ResultType 9 | from ytspdl.utils import extract_playlist_id 10 | 11 | 12 | @dataclass 13 | class Youtube(MusicService): 14 | client: YTMusic 15 | 16 | def _parse_track(self, track: dict) -> Song: 17 | return Song( 18 | title=track["title"], 19 | album=track["album"]["name"] if track["album"] else None, 20 | artist=track["artists"][0]["name"], 21 | imgurl=f'https://i.ytimg.com/vi/{track["videoId"]}/sddefault.jpg' 22 | ) 23 | 24 | def _parse_songs(self, results: dict, limit: int, result_type: ResultType) -> list[Song]: 25 | match result_type: 26 | case ResultType.PLAYLIST: 27 | results = results["tracks"] 28 | case ResultType.INDIVIDUAL: 29 | results = results 30 | case ResultType.LIKED: 31 | pass 32 | 33 | return [ 34 | self._parse_track(track) 35 | for count, track in enumerate(results, 1) 36 | if count <= limit 37 | ] 38 | 39 | def _fetch_songs(self, api: callable, result_type: ResultType, limit: int = None) -> list[Song]: 40 | if limit is None: 41 | limit = 1000 42 | 43 | results = api(limit=limit) 44 | return self._parse_songs(results=results, limit=limit, result_type=result_type) 45 | 46 | def get_playlist_songs(self, playlist_url: str, limit: int = None) -> list[Song]: 47 | playlist_id = extract_playlist_id(playlist_url=playlist_url) 48 | api_source = functools.partial(self.client.get_playlist, playlistId=playlist_id) 49 | return self._fetch_songs(api=api_source, result_type=ResultType.PLAYLIST, limit=limit) 50 | 51 | def get_liked_songs(self, limit: int = None) -> list[Song]: 52 | raise NotImplementedError("Requires youtube authentication. Not Implemented yet") 53 | 54 | def get_song(self, song_name: str) -> Song: 55 | results = self.client.search(query=song_name) 56 | return self._parse_songs(results=results, limit=1, result_type=ResultType.INDIVIDUAL) -------------------------------------------------------------------------------- /ytspdl/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from ytspdl.utils.helpers import extract_playlist_id 2 | from ytspdl.utils.helpers import fetch_youtube_video_id 3 | from ytspdl.utils.metadata import add_tags_to_song 4 | from ytspdl.utils.helpers import get_sanitized_song_name 5 | from ytspdl.utils.helpers import download_song_from_youtube 6 | from ytspdl.utils.downloader import SongDownloader -------------------------------------------------------------------------------- /ytspdl/utils/downloader.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from ytspdl.models import Song 4 | from ytspdl.utils import fetch_youtube_video_id 5 | from ytspdl.utils import add_tags_to_song 6 | from ytspdl.utils import get_sanitized_song_name 7 | from ytspdl.utils import download_song_from_youtube 8 | 9 | 10 | class SongDownloader: 11 | def __init__(self, song: Song, download_location: str = "./"): 12 | self.song = song 13 | self.download_location = download_location 14 | self.song_name = get_sanitized_song_name(song=song) 15 | self.song_path = f"{self.download_location}/{self.song_name}.m4a" 16 | 17 | if self.song.vidurl is None: 18 | video_id = fetch_youtube_video_id(song_name=self.song_name) 19 | self.song.vidurl = f"https://www.youtube.com/watch?v={video_id}" 20 | 21 | def download(self): 22 | print(f"Downloading {self.song_name}...") 23 | if os.path.exists(self.song_path): 24 | print(f"Skipping {self.song_name}: Song Already Exists!") 25 | return 26 | 27 | try: 28 | download_song_from_youtube(video_url=self.song.vidurl, song_path=self.song_path) 29 | add_tags_to_song(song_path=self.song_path, song=self.song) 30 | except Exception as e: 31 | print(f"Error downloading song {self.song_name}, Error Message: {e.with_traceback()}") 32 | if os.path.exists(self.song_path): 33 | os.remove(self.song_path) -------------------------------------------------------------------------------- /ytspdl/utils/helpers.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import requests 4 | import aiohttp 5 | import yt_dlp 6 | 7 | from ytspdl.models import Song 8 | 9 | 10 | def extract_playlist_id(playlist_url: str) -> str | None: 11 | '''Extract playlist id from youtube playlist url''' 12 | match = re.match('.*list=(.*)', playlist_url) 13 | return match and match.group(1) 14 | 15 | 16 | async def fetch_youtube_video_id_async(song_name: str) -> str | None: 17 | '''Fetch youtube video id for a given song name asynchronously''' 18 | 19 | # Replacing whitespace with '+' symbol, since search query cannot have whitespace 20 | query = "+".join(song_name.split()).encode("utf-8") 21 | url = f"https://www.youtube.com/results?search_query={query}" 22 | 23 | async with aiohttp.ClientSession() as session: 24 | async with session.get(url) as response: 25 | html = await response.text() 26 | vid_ids = re.findall(r"watch\?v=(\S{11})", html) 27 | return vid_ids[0] if vid_ids else None 28 | 29 | 30 | def fetch_youtube_video_id(song_name: str) -> str | None: 31 | '''Fetch youtube video id for a given song name''' 32 | 33 | # Replacing whitespace with '+' symbol, since search query cannot have whitespace 34 | query = "+".join(song_name.split()).encode("utf-8") 35 | url = f"https://www.youtube.com/results?search_query={query}" 36 | html = requests.get(url) 37 | 38 | # Search for all video ids in the html page 39 | video_ids = re.findall(r"watch\?v=(\S{11})", html.text) 40 | return video_ids[0] if video_ids else None 41 | 42 | 43 | def get_sanitized_song_name(song: Song) -> str: 44 | '''Remove all invalid characters from song name''' 45 | INVALID_CHARACTERS = r"[#<%>&\*\{\?\}/\\$+!`'\|\"=@\.\[\]:]*" 46 | song_name = re.sub(INVALID_CHARACTERS, "", f"{song.artist} {song.title}") 47 | return song_name 48 | 49 | 50 | def download_song_from_youtube(video_url: str, song_path: str) -> None: 51 | '''Download only audio (m4a) from youtube for the given video url''' 52 | ydl_opts = { 53 | "format": "m4a/bestaudio/best", 54 | "outtmpl": song_path, 55 | "quiet": True, 56 | "no_warnings": True, 57 | "newline": True 58 | } 59 | 60 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 61 | ydl.download([video_url]) -------------------------------------------------------------------------------- /ytspdl/utils/metadata.py: -------------------------------------------------------------------------------- 1 | from mutagen.mp4 import MP4, MP4Cover 2 | import requests 3 | 4 | from ytspdl.models.song import Song 5 | 6 | 7 | def add_tags_to_song(song_path: str, song: Song) -> None: 8 | TITLE = "\xa9nam" 9 | ALBUM = "\xa9alb" 10 | ARTIST = "\xa9ART" 11 | ART = "covr" 12 | f = MP4(song_path) 13 | f[TITLE] = song.title 14 | if song.album is not None: 15 | f[ALBUM] = song.album 16 | f[ARTIST] = song.artist 17 | image_response = requests.get(song.imgurl) 18 | f[ART] = [MP4Cover(image_response.content, MP4Cover.FORMAT_JPEG)] 19 | f.save() --------------------------------------------------------------------------------