├── .gitignore ├── .python-version ├── .vscode └── spellright.dict ├── LICENSE ├── README.md ├── data └── .gitignore ├── requirements.txt └── src ├── __init__.py ├── main.py └── youtube_music.py /.gitignore: -------------------------------------------------------------------------------- 1 | # OS 2 | .DS_Store 3 | 4 | # Python 5 | venv/ 6 | __pycache__/ 7 | 8 | # ytmusicai 9 | oauth.json 10 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11.3 2 | -------------------------------------------------------------------------------- /.vscode/spellright.dict: -------------------------------------------------------------------------------- 1 | jonathanbell 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 jonathanbell 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 NON-INFRINGEMENT. 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spotify to YouTube Music Migration Script 2 | 3 | So.. You want to give your money to one of the largest corporations in the world instead of the little 2006 Swedish company that could (_"I think I can.. I think I can.."_) OK, well, here is a way for you to import your Spotify library into YouTube Music, you filthy animal. 4 | 5 | Ugh.. I can't even believe I'm helping you do this. The only reason I made this script was to swap my subscription over to YT Music from Spotify "seamlessly". I feel like the pond scum of the Earth for doing so. I'm sorry, Daniel. 6 | 7 | First of all, you need to export your Spotify library into a format that this script will understand. Probably, the easiest way for you to do that is to use [this tool](https://github.com/jonathanbell/spotify_export) that I also wrote. The migration script in this repo is designed to be used with the JSON output produced by this [Spotify Export tool](https://github.com/jonathanbell/spotify_export). 8 | 9 | Simply, [download a binary that matches your system](https://github.com/jonathanbell/spotify_export/releases) (Win/Mac/Linux) and run it. If you are on a Mac, you'll have to make the binary executable: `chmod +x /path/to/spotify_export-mac64 && ./path/to/spotify_export-mac64`. After authenticating via Spotify a `spotify_library.json` file will be placed onto your Desktop. _Use this file with this Python script_, provided here (in this repo). 10 | 11 | This script depends _heavily_ on [the `ytmusicapi` package](https://github.com/sigma67/ytmusicapi). Since that package is written in fucking Python, so is this script. 12 | 13 | ⚠️ This is still a work in progress and contributions are welcome. It works on my machine and I imported my Spotify library to YouTube Music without too much headache. Have fun. 14 | 15 | !["I want you to develop free software (for me)"](https://miro.medium.com/v2/resize:fit:751/1*0zSv0aE2Whxf0ecf1PGRuw.jpeg) 16 | 17 | ## Prerequisites 18 | 19 | - Python version 3.12.x (it's probably easiest to use a Python virtual environment - see below) 20 | - `pip` 21 | - `git` (you probably already have this installed on Mac or Linux) 22 | 23 | ## Installation 24 | 25 | 1. Clone/download this repo and `cd` into its directory 26 | 1. `python3 -m venv venv` (_or_ `python -m venv venv` if `python` already points to version 3 on your system) 27 | 1. `source venv/bin/activate` will activate the virtual environment (if you're into that kind of thing) 28 | 1. `pip install -r requirements.txt` 29 | 1. The script has a major dependency on [the `ytmusicapi` Python package](https://github.com/sigma67/ytmusicapi?tab=readme-ov-file). So before running any import commands, we need to [authenticate with the YouTube Music API](https://ytmusicapi.readthedocs.io/en/stable/setup/oauth.html) in order for it to work. Run: `ytmusicapi oauth` (and follow the prompts in the CLI and browser) 30 | 1. If using `pyenv`, run `python3.11 ./src/main.py --help`. Otherwise run `python3 ./src/main.py --help` or `python ./src/main.py --help` (if you have `python` setup to run as Python version 3 on your system). This command will list all of the available options/functionality associated with the script. 31 | 32 | ## Usage 33 | 34 | Place your `spotify_library.json` file in the `data` directory (eg. `data/spotify_library.json`) before running any of the commands. 35 | 36 | If a song, artist, etc. cannot be found by the script while searching YT Music, the item will end up in the `lost_and_found.txt` file here: `data/lost_and_found.txt`. You can think of this file as a log of items that was not added to YT Music for whatever reason. 37 | 38 | The output of `--help` should be clear enough but I'll list a few examples here. 39 | 40 | ### Playlists 41 | 42 | `--spotify-playlists` List all of the available playlists to import (via the `spotify_library.json` file) 43 | 44 | `./src/main.py --playlists` will import all of your Spotify playlists into YouTube Music. 45 | 46 | For a more fine-grained/surgical approach, you can pass the names of which playlists to import via the `--lists` property. Separate the names of the playlists with a comma. 47 | 48 | Example: `./src/main.py --playlists --lists='lofi beats, Your Top Songs 2023'` 49 | 50 | ### Followed artists 51 | 52 | `--followed-artists` will import your existing flowed artists from Spotify into YT Music and "subscribe" you to each artist. 53 | 54 | Example: `python3.11 ./src/main.py --followed-artists` 55 | 56 | ### Liked songs 57 | 58 | `--liked-songs` will import all of your Spotify Liked Songs into YT Music's "Liked Music" list. 59 | 60 | Example: `python3.11 ./src/main.py --liked-songs` 61 | 62 | ### Saved albums 63 | 64 | `--saved-albums` creates new playlists with the artist and album name. Adds the tracks that are on the album to the playlist. 65 | 66 | Example: `python3.11 ./src/main.py --saved-albums` 67 | 68 | ## Errors do be like that 69 | 70 | It would seem that the unofficial, designed-for-browser YT Music API doesn't take kindly to overuse. I've encountered the error below more than once. Basically, just do what it says; _wait a little while and try again_. 71 | 72 | ```plaintext 73 | Exception: Server returned HTTP 400: Bad Request. 74 | You are creating too many playlists. Please wait a while before creating further playlists. 75 | ``` 76 | 77 | ### YouTube (might) get mad 78 | 79 | 🚨 If you are worried about your YT account getting banned or flagged for scripting, using the `--slow-motion` flag might be helpful. Basically, the script will operate in a much slower manner when slow motion is enabled in an attempt to not be flagged as "non-human activity" by YT Music. 80 | 81 | This script/open source repository offers _no warranty_ and will not be help liable for anything your stupid ass does. The code and functionality is offered as is. See the included LICENSE file. 82 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ytmusicapi==1.7.5 2 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonathanbell/spotify2ytmusic-migration-script/aa17aae343c1c24eba4f32a622df52eb23d706f0/src/__init__.py -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from youtube_music import YoutubeMusic 3 | 4 | ytmusic = YoutubeMusic() 5 | 6 | 7 | def main(): 8 | parser = argparse.ArgumentParser( 9 | description="Migrate your Spotify data to YouTube Music. Use https://github.com/jonathanbell/spotify_export to export your Spotify data and create your spotify_library.json file before using this script." 10 | ) 11 | parser.add_argument( 12 | "--playlists", 13 | action="store_true", 14 | help="Import playlists from Spotify to YouTube Music. Requires the data/spotify_library.json file.", 15 | ) 16 | parser.add_argument( 17 | "--lists", 18 | type=str, 19 | help="Specify a comma separated list of playlists to import. Must be used with --playlists.", 20 | ) 21 | parser.add_argument( 22 | "--followed-artists", 23 | action="store_true", 24 | help="Import followed artists from Spotify to YouTube Music. Requires the data/spotify_library.json file.", 25 | ) 26 | parser.add_argument( 27 | "--liked-songs", 28 | action="store_true", 29 | help="Import liked songs from Spotify to YouTube Music Liked Music. Requires the data/spotify_library.json file.", 30 | ) 31 | parser.add_argument( 32 | "--saved-albums", 33 | action="store_true", 34 | help="Import saved albums from Spotify to YouTube Music. Requires the data/spotify_library.json file.", 35 | ) 36 | parser.add_argument( 37 | "--spotify-playlists", 38 | action="store_true", 39 | help="List all importable Spotify playlists from the spotify_library.json file.", 40 | ) 41 | parser.add_argument( 42 | "--slow-motion", 43 | action="store_true", 44 | help="Call the unofficial YouTube Music API in slow motion. Will likely help prevent your account from being banned.", 45 | ) 46 | 47 | args = parser.parse_args() 48 | 49 | if args.spotify_playlists: 50 | ytmusic.list_importable_playlists() 51 | elif args.playlists: 52 | if args.lists: 53 | ytmusic.import_playlists( 54 | playlist_names=[playlist.strip() for playlist in args.lists.split(",")], 55 | slow=args.slow_motion, 56 | ) 57 | else: 58 | ytmusic.import_playlists(playlist_names=None, slow=args.slow_motion) 59 | elif args.followed_artists: 60 | ytmusic.import_followed_artists(slow=args.slow_motion) 61 | elif args.liked_songs: 62 | ytmusic.import_liked_songs(slow=args.slow_motion) 63 | elif args.saved_albums: 64 | ytmusic.import_saved_albums(slow=args.slow_motion) 65 | else: 66 | print("No valid arguments provided. Use --help for more information.") 67 | 68 | 69 | if __name__ == "__main__": 70 | main() 71 | -------------------------------------------------------------------------------- /src/youtube_music.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import random 4 | import sys 5 | import time 6 | from ytmusicapi import YTMusic 7 | 8 | 9 | class YoutubeMusic: 10 | def __init__(self, max_retries=3, initial_delay=60): 11 | oauth_file_path = "oauth.json" 12 | if not os.path.exists(oauth_file_path): 13 | print( 14 | "The './oauth.json' file is missing. Please create it by running 'ytmusicapi oauth'." 15 | ) 16 | sys.exit(1) 17 | spotify_library_path = "data/spotify_library.json" 18 | if not os.path.exists(spotify_library_path): 19 | print( 20 | "Error: 'spotify_library.json' file is missing in the data directory. Migrate your Spotify data to YouTube Music. Use https://github.com/jonathanbell/spotify_export to export your Spotify data and create your spotify_library.json file and place that file inside the data directory before using this script." 21 | ) 22 | sys.exit(1) 23 | try: 24 | with open(spotify_library_path, "r") as file: 25 | spotify_library = json.load(file) 26 | except json.JSONDecodeError: 27 | print("Error: 'spotify_library.json' is not a valid JSON file.") 28 | sys.exit(1) 29 | required_keys = ["followed_artists", "liked_songs", "playlists", "saved_albums"] 30 | missing_keys = [key for key in required_keys if key not in spotify_library] 31 | if missing_keys: 32 | print( 33 | f"Error: The following required keys are missing in 'spotify_library.json': {', '.join(missing_keys)}" 34 | ) 35 | sys.exit(1) 36 | self.spotify_data = type("SpotifyData", (object,), spotify_library) 37 | try: 38 | self.ytmusicapi = YTMusic(oauth_file_path) 39 | except Exception as e: 40 | print(f"Error initializing YTMusic: {str(e)}") 41 | sys.exit(1) 42 | self.max_retries = max_retries 43 | self.initial_delay = initial_delay 44 | 45 | def import_followed_artists(self, slow=False): 46 | print("Importing followed artists...") 47 | if len(self.spotify_data.followed_artists) == 0: 48 | print("No followed artists to import.") 49 | return 50 | for artist in self.spotify_data.followed_artists: 51 | try: 52 | yt_candidates = self.ytmusicapi.search( 53 | query=artist["name"], filter="artists" 54 | ) 55 | if len(yt_candidates) == 0: 56 | self._add_to_lost_and_found("artist", artist["name"]) 57 | continue 58 | self.ytmusicapi.subscribe_artists([yt_candidates[0]["browseId"]]) 59 | print(f"Subscribed to: {yt_candidates[0]['artist']}") 60 | if slow: 61 | print("🐢 Slow motion enabled. Sleeping for 1-3 seconds.") 62 | time.sleep(random.uniform(1, 3)) 63 | except Exception as e: 64 | print(f"Error processing artist {artist['name']}: {str(e)}") 65 | continue 66 | 67 | def import_liked_songs(self, slow=False): 68 | print("Importing liked songs to Liked Music...") 69 | if self.spotify_data.liked_songs[0]["track_count"] == 0: 70 | print("No liked songs to import.") 71 | return 72 | print("Searching for liked songs...") 73 | counter = 1 74 | for song in reversed(self.spotify_data.liked_songs[0]["tracks"]): 75 | try: 76 | matched_yt_song = self._search_for_song( 77 | song["name"], song["artists"][0]["name"], song["album"]["name"] 78 | ) 79 | if slow: 80 | print("🐢 Slow motion enabled. Sleeping for 1 second.") 81 | time.sleep(1) 82 | if matched_yt_song: 83 | artist_name = matched_yt_song['artists'][0]['name'] if matched_yt_song.get('artists') else 'Unknown Artist' 84 | print( 85 | f"Found on YT Music ({counter}/{len(self.spotify_data.liked_songs[0]['tracks'])}): {matched_yt_song['title']} - {artist_name}" 86 | ) 87 | self.ytmusicapi.rate_song(matched_yt_song["videoId"], "LIKE") 88 | print("...Liked 👍") 89 | if slow: 90 | print("🐢 Slow motion enabled. Sleeping for 1-3 seconds.") 91 | time.sleep(random.uniform(1, 3)) 92 | else: 93 | self._add_to_lost_and_found( 94 | "song", f"{song['name']} by {song['artists'][0]['name']}" 95 | ) 96 | except Exception as e: 97 | print(f"Error processing song {song.get('name', 'Unknown')}: {str(e)}") 98 | continue 99 | counter += 1 100 | print(f"Added {counter - 1} songs to Liked Music.") 101 | 102 | def import_playlists(self, playlist_names=None, slow=False): 103 | max_batch_size = 50 104 | print("Importing playlists...") 105 | if len(self.spotify_data.playlists) == 0: 106 | print("No playlists to import.") 107 | return 108 | if playlist_names: 109 | playlists = [playlist for playlist in self.spotify_data.playlists if playlist["name"] in playlist_names] 110 | if not playlists: 111 | print(f"🚨 No matching playlists found in spotify_library.json file.") 112 | return 113 | else: 114 | playlists = self.spotify_data.playlists 115 | 116 | for playlist in playlists: 117 | playlist_id = None 118 | retry_count = 0 119 | delay = self.initial_delay 120 | while retry_count <= self.max_retries: 121 | try: 122 | # Check if playlist already exists 123 | existing_playlists = self.ytmusicapi.get_library_playlists(limit=None) 124 | existing_playlist = next((p for p in existing_playlists if p['title'] == playlist["name"]), None) 125 | if existing_playlist: 126 | print(f"Playlist '{playlist['name']}' already exists. Using existing playlist.") 127 | playlist_id = existing_playlist['playlistId'] 128 | break 129 | 130 | playlist_id = self.ytmusicapi.create_playlist( 131 | playlist["name"], "Created by the Spotify to YTMusic Migration Script" 132 | ) 133 | print(f"📝 Created playlist: {playlist_id} - {playlist['name']}") 134 | 135 | # If successful, break the retry loop 136 | break 137 | except Exception as e: 138 | if "You are creating too many playlists" in str(e) or "Server returned HTTP 400" in str(e): 139 | retry_count += 1 140 | if retry_count > self.max_retries: 141 | print(f"Failed to create playlist '{playlist['name']}' after {self.max_retries} retries. Skipping.") 142 | break 143 | print(f"Rate limit reached. Retrying in {delay} seconds... (Attempt {retry_count}/{self.max_retries})") 144 | time.sleep(delay) 145 | delay *= 2 # Exponential backoff 146 | else: 147 | print(f"Error creating playlist '{playlist['name']}': {str(e)}") 148 | break 149 | 150 | if playlist_id is None: 151 | print(f"Skipping song addition for playlist '{playlist['name']}' due to creation failure.") 152 | continue 153 | 154 | if slow: 155 | print("🐢 Slow motion enabled. Sleeping for 1-3 seconds.") 156 | time.sleep(random.uniform(1, 3)) 157 | 158 | video_ids = [] 159 | added_songs_counter = 0 160 | print(f"Searching for playlist songs... (Total: {len(playlist['tracks'])})") 161 | for song in playlist["tracks"]: 162 | if not song.get("artists"): 163 | print(f"Skipping song without artist information: {song.get('name', 'Unknown')}") 164 | continue 165 | try: 166 | matched_yt_song = self._search_for_song( 167 | song["name"], song["artists"][0]["name"], song.get("album", {}).get("name", "") 168 | ) 169 | if slow: 170 | print("🐢 Slow motion enabled. Sleeping for 1 second.") 171 | time.sleep(1) 172 | if matched_yt_song: 173 | artist_name = matched_yt_song['artists'][0]['name'] if matched_yt_song.get('artists') else 'Unknown Artist' 174 | print(f"Found on YT Music: {matched_yt_song['title']} - {artist_name}") 175 | video_ids.append(matched_yt_song["videoId"]) 176 | added_songs_counter += 1 177 | if len(video_ids) >= max_batch_size: 178 | self._add_songs_to_playlist(playlist_id, video_ids, playlist['name'], slow) 179 | video_ids = [] 180 | else: 181 | self._add_to_lost_and_found( 182 | "song", f"{song['name']} by {song['artists'][0]['name']}" 183 | ) 184 | except Exception as e: 185 | print(f"Error processing song: {song.get('name', 'Unknown')} - {str(e)}") 186 | continue 187 | if video_ids: 188 | self._add_songs_to_playlist(playlist_id, video_ids, playlist['name'], slow) 189 | 190 | # Check if all songs were added 191 | yt_playlist = self.ytmusicapi.get_playlist(playlist_id) 192 | yt_song_count = yt_playlist['trackCount'] 193 | spotify_song_count = len(playlist['tracks']) 194 | print(f"Added {added_songs_counter} songs to {playlist['name']}.") 195 | print(f"YouTube Music playlist has {yt_song_count} songs, Spotify playlist had {spotify_song_count} songs.") 196 | if yt_song_count < spotify_song_count: 197 | print(f"⚠️ Warning: {spotify_song_count - yt_song_count} songs could not be added to the YouTube Music playlist.") 198 | 199 | def import_saved_albums(self, slow=False): 200 | print("Importing saved albums...") 201 | if len(self.spotify_data.saved_albums) == 0: 202 | print("No saved albums to import.") 203 | return 204 | for album in self.spotify_data.saved_albums: 205 | try: 206 | print( 207 | "Searching for: " + album["name"] + " by " + album["artists"][0]["name"] 208 | ) 209 | yt_album_candidates = self.ytmusicapi.search( 210 | query=f'{album["name"]} by {album["artists"][0]["name"]}', 211 | filter="albums", 212 | ) 213 | if slow: 214 | print("🐢 Slow motion enabled. Sleeping for 1 second.") 215 | time.sleep(1) 216 | found = False 217 | for yt_album_candidate in yt_album_candidates: 218 | if not yt_album_candidate.get("playlistId"): 219 | print( 220 | "🚨 No playlist ID found in data returned from search! Cannot add an album to library without a valid playlist ID for a given album." 221 | ) 222 | continue 223 | if yt_album_candidate["title"] == album["name"]: 224 | print(yt_album_candidate) 225 | self.ytmusicapi.rate_playlist( 226 | yt_album_candidate["playlistId"], "LIKE" 227 | ) 228 | print( 229 | f'💿 Added album: {yt_album_candidate["title"]} - {album["artists"][0]["name"]}' 230 | ) 231 | found = True 232 | if slow: 233 | print("🐢 Slow motion enabled. Sleeping for 1-3 seconds.") 234 | time.sleep(random.uniform(1, 3)) 235 | break 236 | if not found: 237 | self._add_to_lost_and_found( 238 | "album", f"{album['name']} by {album['artists'][0]['name']}" 239 | ) 240 | except Exception as e: 241 | print(f"Error processing album {album.get('name', 'Unknown')}: {str(e)}") 242 | continue 243 | 244 | def list_importable_playlists(self): 245 | print("Importable playlists:") 246 | for playlist in self.spotify_data.playlists: 247 | print(playlist["name"]) 248 | 249 | def _search_for_song(self, track_name: str, artist_name: str, album_name: str): 250 | try: 251 | song = self.ytmusicapi.search( 252 | query=f"{track_name} by {artist_name}", filter="songs", limit=1 253 | ) 254 | if song and len(song) > 0: 255 | return song[0] 256 | else: 257 | print("Performing deeper search...") 258 | song = self.ytmusicapi.search( 259 | query=f"{track_name} by {artist_name} on {album_name}", 260 | filter="songs", 261 | limit=1, 262 | ) 263 | if song and len(song) > 0: 264 | print("Found song! 🙂 (" + song[0]["title"] + ")") 265 | return song[0] 266 | self._add_to_lost_and_found("song", f"{track_name} by {artist_name}") 267 | return None 268 | except Exception as e: 269 | print(f"Error searching for song {track_name}: {str(e)}") 270 | return None 271 | 272 | def _add_songs_to_playlist(self, playlist_id, video_ids, playlist_name, slow): 273 | print(f"Adding {len(video_ids)} songs to {playlist_name} playlist... 👆") 274 | try: 275 | result = self.ytmusicapi.add_playlist_items(playlist_id, video_ids, None, True) 276 | if result["status"] == "STATUS_SUCCEEDED": 277 | print("...✅") 278 | if slow: 279 | print("🐢 Slow motion enabled. Sleeping for 1-3 seconds.") 280 | time.sleep(random.uniform(1, 3)) 281 | else: 282 | print(f"❌ Error while adding songs to playlist ({playlist_name}): {result['status']}") 283 | if 'actions' in result and result['actions']: 284 | print(result["actions"][0]["confirmDialogEndpoint"]["content"]["confirmDialogRenderer"]["dialogMessages"][0]["runs"][0]["text"]) 285 | except Exception as e: 286 | print(f"Error adding songs to playlist {playlist_name}: {str(e)}") 287 | 288 | def _add_to_lost_and_found(self, type, value): 289 | print(f"🤷 Could not find (adding to lost and found): {type} | {value}") 290 | lost_and_found_path = "data/lost_and_found.txt" 291 | try: 292 | with open(lost_and_found_path, "a") as file: 293 | file.write(f"{type} - {value}\n") 294 | except IOError as e: 295 | print(f"Error writing to lost and found file: {str(e)}") 296 | --------------------------------------------------------------------------------