├── .gitignore ├── LICENSE.txt ├── README.md ├── main.py ├── requirements.txt └── utils ├── audio.py ├── metadata.py └── token.py /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /.venv/ 3 | *.mp3 4 | *.m4a 5 | *.wvd 6 | spotify_*.txt 7 | __pycache__ -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2024 Yuuto 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spotify Downloader 2 | 3 | A Python script to download music straight from Spotify servers lol 4 | 5 | ## Features 6 | 7 | - Supports downloading music from Spotify (that's the point of this program) 8 | - As it downloads music directly from Spotify servers, the quality is the same as you can hear it on Spotify 9 | - Of course, it decrypts the DRM encryption with pywidevine 10 | - **Soon:** downloading playlists and albums 11 | 12 | ## Usage 13 | 14 | ```bash 15 | git clone https://github.com/JustYuuto/spotify-downloader.git 16 | cd spotify-downloader 17 | pip install -r requirements.txt 18 | python main.py 19 | # Note: the track id can be a normal id, a gid, or a spotify uri (spotify:track:xxxxxx....) 20 | ``` 21 | 22 | Please see [https://cdm-project.com/How-To/Dumping-L3-from-Android](https://cdm-project.com/Android-Tools/KeyDive) to get the `device.wvd` file. 23 | 24 | The script will likely ask you for your "sp_dc" cookie, you can get it by logging into Spotify web, and getting the cookie from the storage (in devtools). 25 | 26 | > [!NOTE] 27 | > You **need** to have a Spotify Premium account to download music at 160kbps+ 28 | 29 | ## Requirements 30 | 31 | - [Python 3.9](https://www.python.org/downloads/release/python-390/) (due to the package `pywidevine` and compatibility reasons) 32 | - [FFmpeg](https://www.ffmpeg.org/download.html) (in ur path), this is required for decrypting mp3s 33 | 34 | 35 | ## License 36 | 37 | The source code is licensed under the MIT license. You can view it [here](LICENSE.txt). 38 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from os.path import abspath, isfile 2 | from os import remove, rename, environ 3 | import requests 4 | from utils.audio import Audio 5 | from pywidevine.cdm import Cdm 6 | from pywidevine.device import Device 7 | from pywidevine.pssh import PSSH 8 | from utils.metadata import Metadata 9 | import subprocess 10 | import argparse 11 | from utils.token import AccessToken 12 | from librespot.metadata import TrackId 13 | 14 | parser = argparse.ArgumentParser( 15 | prog='Spotify Downloader', 16 | description='it downloads spotify songs') 17 | parser.add_argument('track_id', type=str, help='The track id of the song. Public IDs, GIDs and Spotify URIs are supported') 18 | parser.add_argument('--add-metadata', type=bool, 19 | help='Should add metadata to the song? (like artists, album, cover, etc). Defaults to false.', 20 | default=False, required=False) 21 | parser.add_argument('--ffmpeg-path', type=str, 22 | help='Path to the ffmpeg executable (if not in PATH)', required=False) 23 | parser.add_argument('--quality', type=str, 24 | help='The quality to download. Defaults to MP4_256.', 25 | default='MP4_256', required=False, choices=[ 26 | 'OGG_VORBIS_96', 'OGG_VORBIS_160', 'OGG_VORBIS_320', 27 | 'MP4_128', 'MP4_128_DUAL', 'MP4_256', 'MP4_256_DUAL', 28 | 'AAC_24' 29 | ]) 30 | args = parser.parse_args() 31 | 32 | if __name__ == '__main__': 33 | if not isfile('device.wvd'): 34 | print('You need to have a device.wvd file in the same directory as this script') 35 | exit(1) 36 | 37 | if isinstance(args.ffmpeg_path, str) and not isfile(args.ffmpeg_path): 38 | print('Error: FFmpeg was not found in the specified path!') 39 | exit(1) 40 | elif not isinstance(args.ffmpeg_path, str) and not 'ffmpeg' in environ['PATH']: 41 | print('Error: FFmpeg was not found in your path!') 42 | exit(1) 43 | 44 | if not isfile('spotify_dc.txt'): 45 | user_token = input('Enter your Spotify "sp_dc" cookie: ') 46 | with open('spotify_dc.txt', 'w') as file: 47 | file.write(user_token.replace('Bearer ', '')) 48 | else: 49 | user_token = open('spotify_dc.txt', 'r').read() 50 | 51 | track_id = args.track_id 52 | 53 | if len(track_id) == 22: 54 | track_id = TrackId.from_base62(track_id).get_gid().hex() 55 | elif 'spotify:track:' in track_id: 56 | track_id = TrackId.from_base62(track_id.replace('spotify:track:', '')).get_gid().hex() 57 | 58 | token = AccessToken() 59 | audio = Audio() 60 | metadata = Metadata() 61 | try: 62 | track = audio.get_track(track_id) 63 | except requests.exceptions.HTTPError as e: 64 | if e.response.status_code == 401: 65 | token.refresh() 66 | track = audio.get_track(track_id) 67 | else: 68 | print('Error:', e) 69 | exit(1) 70 | 71 | def find_quality(track, quality): 72 | for file in track['file']: 73 | if file['format'] == quality: 74 | return file 75 | return None 76 | 77 | pssh = PSSH(requests.get(f"https://seektables.scdn.co/seektable/{track['file'][4]['file_id']}.json").json()['pssh']) 78 | device = Device.load('device.wvd') 79 | cdm = Cdm.from_device(device) 80 | session_id = cdm.open() 81 | 82 | challenge = cdm.get_license_challenge(session_id, pssh) 83 | license = requests.post(audio.license_url, headers={ 84 | 'Accept': '*/*', 85 | 'Accept-Encoding': 'gzip, deflate, br', 86 | 'Accept-Language': 'en', 87 | 'authorization': f'Bearer {AccessToken().access_token()}', 88 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', 89 | }, data=challenge) 90 | license.raise_for_status() 91 | 92 | cdm.parse_license(session_id, license.content) 93 | 94 | cdn_file = find_quality(track, args.quality) 95 | url = audio.get_audio_urls(cdn_file['file_id'])[0] 96 | audio = requests.get(url) 97 | audio.raise_for_status() 98 | audio_type = cdn_file['format'].split('_')[0].lower().replace('mp4', 'm4a') 99 | audio_file = abspath(f"./{track['name']}-encrypted.{audio_type}") 100 | audio_file_decrypted = abspath(f"./{track['name']}.{audio_type}") 101 | 102 | if isfile(audio_file): 103 | remove(audio_file) 104 | if isfile(audio_file_decrypted): 105 | remove(audio_file_decrypted) 106 | 107 | with open(audio_file, 'wb') as file: 108 | file.write(audio.content) 109 | file.close() 110 | 111 | for key in cdm.get_keys(session_id): 112 | try: 113 | path = args.ffmpeg_path if isinstance(args.ffmpeg_path, str) else 'ffmpeg' 114 | cmd = [ 115 | path, '-decryption_key', key.key.hex(), '-i', audio_file, audio_file_decrypted 116 | ] 117 | subprocess.run(cmd, stdout=None, stderr=None, stdin=None, shell=False, check=True) 118 | except Exception as e: 119 | print('Error:', e) 120 | exit(1) 121 | cdm.close(session_id) 122 | 123 | remove(audio_file) 124 | 125 | if args.add_metadata == True: 126 | print('Adding metadata to the song...') 127 | metadata.set_metadata(track, audio_file_decrypted) 128 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests~=2.31.0 2 | protobuf<=3.20.* 3 | pywidevine 4 | eyed3~=0.9.7 5 | librespot -------------------------------------------------------------------------------- /utils/audio.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from utils.token import AccessToken 3 | 4 | 5 | class Audio: 6 | 7 | pssh = '' 8 | license_url = 'https://gew1-spclient.spotify.com/widevine-license/v1/audio/license' 9 | 10 | def __init__(self): 11 | pass 12 | 13 | def get_track_url(self, track_id): 14 | return f'https://spclient.wg.spotify.com/metadata/4/track/{track_id}?market=from_token' 15 | 16 | def get_track(self, track_id): 17 | url = self.get_track_url(track_id) 18 | headers = { 19 | 'Accept': 'application/json', 20 | 'Accept-Encoding': 'gzip, deflate, br', 21 | 'Accept-Language': 'en', 22 | 'Authorization': f'Bearer {AccessToken().access_token()}', 23 | 'Connection': 'keep-alive', 24 | 'Host': 'spclient.wg.spotify.com', 25 | 'Origin': 'https://open.spotify.com', 26 | 'Prefer': 'safe', 27 | 'Referer': 'https://open.spotify.com/', 28 | 'Sec-Fetch-Dest': 'empty', 29 | 'Sec-Fetch-Mode': 'cors', 30 | 'Sec-Fetch-Site': 'same-site', 31 | 'Sec-GPC': '1', 32 | 'TE': 'Trailers', 33 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', 34 | } 35 | request = requests.get(url, headers=headers) 36 | request.raise_for_status() 37 | json = request.json() 38 | 39 | return json 40 | 41 | def get_audio_urls(self, file_id): 42 | url = f'https://gew1-spclient.spotify.com/storage-resolve/v2/files/audio/interactive/11/{file_id}' 43 | params = { 44 | 'version': 10000000, 45 | 'product': 9, 46 | 'platform': 39, 47 | 'alt': 'json' 48 | } 49 | headers = { 50 | 'Accept': 'application/json', 51 | 'Accept-Encoding': 'gzip, deflate, br', 52 | 'Accept-Language': 'en', 53 | 'Authorization': f'Bearer {AccessToken().access_token()}', 54 | 'Connection': 'keep-alive', 55 | 'Host': 'gew1-spclient.spotify.com', 56 | 'Origin': 'https://open.spotify.com', 57 | 'Prefer': 'safe', 58 | 'Referer': 'https://open.spotify.com/', 59 | 'Sec-Fetch-Dest': 'empty', 60 | 'Sec-Fetch-Mode': 'cors', 61 | 'Sec-Fetch-Site': 'same-site', 62 | 'Sec-GPC': '1', 63 | 'TE': 'Trailers', 64 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', 65 | } 66 | request = requests.get(url, params=params, headers=headers) 67 | request.raise_for_status() 68 | json = request.json() 69 | 70 | return json['cdnurl'] 71 | -------------------------------------------------------------------------------- /utils/metadata.py: -------------------------------------------------------------------------------- 1 | import eyed3 2 | import requests 3 | 4 | 5 | class Metadata: 6 | 7 | def __init__(self): 8 | pass 9 | 10 | def set_metadata(self, metadata, file_path): 11 | audio = eyed3.load(file_path) 12 | audio.initTag() 13 | audio.tag.artist = metadata['artist'][0]['name'] 14 | audio.tag.album = metadata['album']['name'] 15 | audio.tag.album_artist = metadata['album']['artist'][0]['name'] 16 | audio.tag.title = metadata['name'] 17 | audio.tag.track_num = metadata['number'] 18 | audio.tag.release_date = metadata['album']['date']['year'] 19 | cover_url = 'https://i.scdn.co/image/' + metadata['album']['cover_group']['image'][0]['file_id'] 20 | audio.tag.images.set(3, requests.get(cover_url).content, 'image/png') 21 | audio.tag.save() 22 | print('Metadata set successfully') 23 | -------------------------------------------------------------------------------- /utils/token.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from os.path import exists 3 | import json 4 | 5 | 6 | class AccessToken: 7 | 8 | access_token = open('spotify_token.txt', 'r').read() if exists('spotify_token.txt') else '' 9 | dc = open('spotify_dc.txt', 'r').read() if exists('spotify_dc.txt') else '' 10 | 11 | def __init__(self): 12 | pass 13 | 14 | def access_token(self): 15 | return open('spotify_token.txt', 'r').read() if exists('spotify_token.txt') else '' 16 | 17 | def refresh(self): 18 | url = 'https://open.spotify.com/get_access_token' 19 | params = { 20 | 'reason': 'transport', 21 | 'productType': 'web-player' 22 | } 23 | request = requests.get(url, headers={ 24 | 'Accept': 'application/json', 25 | 'Accept-Encoding': 'gzip, deflate, br', 26 | 'Accept-Language': 'en', 27 | 'App-Platform': 'WebPlayer', 28 | 'Connection': 'keep-alive', 29 | 'Cookie': f'sp_dc={self.dc}', 30 | 'Host': 'open.spotify.com', 31 | 'Sec-Fetch-Dest': 'empty', 32 | 'Sec-Fetch-Mode': 'cors', 33 | 'Sec-Fetch-Site': 'same-origin', 34 | 'Spotify-App-Version': '1.2.33.0-unknown', 35 | 'TE': 'trailers', 36 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0', 37 | }, params=params) 38 | request.raise_for_status() 39 | json = request.json() 40 | is_anonymous = json['isAnonymous'] 41 | if is_anonymous: 42 | print('Error: Please use a valid Spotify "sp_dc" cookie.') 43 | exit(1) 44 | token = json['accessToken'] 45 | 46 | self.access_token = token 47 | 48 | with open('spotify_token.txt', 'w') as file: 49 | file.write(token) 50 | 51 | return token 52 | --------------------------------------------------------------------------------