├── .github └── workflows │ └── main.yml ├── .gitignore ├── README.md ├── pyproject.toml ├── requirements.txt └── spotify_web_downloader ├── __init__.py ├── __main__.py ├── cli.py ├── constants.py ├── downloader.py ├── downloader_music_video.py ├── downloader_song.py ├── enums.py ├── models.py ├── spotify_api.py └── utils.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | # Controls when the workflow will run 4 | on: 5 | 6 | # Workflow will run when a release has been published for the package 7 | release: 8 | types: 9 | - published 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | 17 | # This workflow contains a single job called "publish" 18 | publish: 19 | 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | 23 | # Steps represent a sequence of tasks that will be executed as part of the job 24 | steps: 25 | 26 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 27 | - uses: actions/checkout@v3 28 | 29 | - name: Set up Python 3.9 30 | uses: actions/setup-python@v3 31 | with: 32 | python-version: 3.9 33 | cache: pip 34 | 35 | - name: To PyPI using Flit 36 | uses: AsifArmanRahman/to-pypi-using-flit@v1 37 | with: 38 | password: ${{ secrets.PYPI_API_TOKEN }} 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /* 2 | __pycache__ 3 | !spotify_web_downloader 4 | !.gitignore 5 | !pyproject.toml 6 | !README.md 7 | !requirements.txt 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WARNING! This project has been discontinued. Please use Votify instead: https://github.com/glomatico/votify 2 | 3 | 4 | # Spotify Web Downloader 5 | A Python CLI app for downloading songs and music videos directly from Spotify. 6 | 7 | **Discord Server:** https://discord.gg/aBjMEZ9tnq 8 | 9 | ## Features 10 | * Download songs in AAC 128kbps or in AAC 256kbps with a premium account 11 | * Download synced lyrics 12 | * Download music videos with a premium account 13 | * Highly configurable 14 | 15 | ## Prerequisites 16 | * Python 3.8 or higher 17 | * A .wvd file 18 | * A .wvd file contains the Widevine keys from a device and is required to decrypt the files. The easiest method of obtaining one is using KeyDive, which extracts it from an Android device. Detailed instructions can be found here: https://github.com/hyugogirubato/KeyDive. 19 | * .wvd files extracted from emulated devices may not work. 20 | * The cookies file of your Spotify browser session in Netscape format (free or premium) 21 | * You can get your cookies by using one of the following extensions on your browser of choice at the Spotify website with your account signed in: 22 | * Firefox: https://addons.mozilla.org/addon/export-cookies-txt 23 | * Chromium based browsers: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif 24 | * FFmpeg on your system PATH 25 | * Older versions of FFmpeg may not work. 26 | * Up to date binaries can be obtained from the links below: 27 | * Windows: https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases 28 | * Linux: https://johnvansickle.com/ffmpeg/ 29 | 30 | ## Installation 31 | 1. Install the package `spotify-web-downloader` using pip 32 | ```bash 33 | pip install spotify-web-downloader 34 | ``` 35 | 2. Place your cookies file and the .wvd file in the directory from which you will be running spotify-web-downloader and name it `cookies.txt` and `device.wvd` respectively. 36 | 37 | ## Usage 38 | ```bash 39 | spotify-web-downloader [OPTIONS] URLS... 40 | ``` 41 | 42 | ### Examples 43 | * Download a song 44 | ```bash 45 | spotify-web-downloader "https://open.spotify.com/track/18gqCQzqYb0zvurQPlRkpo" 46 | ``` 47 | * Download an album 48 | ```bash 49 | spotify-web-downloader "https://open.spotify.com/album/0r8D5N674HbTXlR3zNxeU1" 50 | ``` 51 | 52 | ## Configuration 53 | spotify-web-downloader can be configured using the command line arguments or the config file. 54 | 55 | The config file is created automatically when you run spotify-web-downloader for the first time at `~/.spotify-web-downloader/config.json` on Linux and `%USERPROFILE%\.spotify-web-downloader\config.json` on Windows. 56 | 57 | Config file values can be overridden using command line arguments. 58 | | Command line argument / Config file key | Description | Default value | 59 | | --------------------------------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------- | 60 | | `--wait-interval`, `-w` / `wait_interval` | Wait interval between downloads in seconds. | `10` | 61 | | `--download-music-video` / `download_music_video` | Attempt to download music videos from songs (can lead to incorrect results). | `false` | 62 | | `--force-premium`, `-f` / `force_premium` | Force to detect the account as premium. | `false` | 63 | | `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` | 64 | | `--overwrite` / `overwrite` | Overwrite existing files. | `false` | 65 | | `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs. | `false` | 66 | | `--save-playlist` / `save_playlist` | Save a M3U8 playlist file when downloading a playlist. | `false` | 67 | | `--lrc-only`, `-l` / `lrc_only` | Download only the synced lyrics. | `false` | 68 | | `--no-lrc` / `no_lrc` | Don't download the synced lyrics. | `false` | 69 | | `--config-path` / - | Path to config file. | `/.spotify-web-downloader/config.json` | 70 | | `--log-level` / `log_level` | Log level. | `INFO` | 71 | | `--print-exceptions` / `print_exceptions` | Print exceptions. | `false` | 72 | | `--cookies-path`, `-c` / `cookies_path` | Path to .txt cookies file. | `./cookies.txt` | 73 | | `--output-path`, `-o` / `output_path` | Path to output directory. | `./Spotify` | 74 | | `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` | 75 | | `--wvd-path` / `wvd_path` | Path to .wvd file. | `./device.wvd` | 76 | | `--ffmpeg-path` / `ffmpeg_path` | Path to FFmpeg binary. | `ffmpeg` | 77 | | `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `MP4Box` | 78 | | `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` | 79 | | `--aria2c-path` / `aria2c_path` | Path to aria2c binary. | `aria2c` | 80 | | `--nm3u8dlre-path` / `nm3u8dlre_path` | Path to N_m3u8DL-RE binary. | `N_m3u8DL-RE` | 81 | | `--remux-mode` / `remux_mode` | Remux mode. | `ffmpeg` | 82 | | `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` | 83 | | `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` | 84 | | `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` | 85 | | `--template-file-multi-disc` / `template_file_multi_disc` | Template file for the tracks that are part of a multi-disc album. | `{disc}-{track:02d} {title}` | 86 | | `--template-folder-no-album` / `template_folder_no_album` | Template folder for the tracks that are not part of an album. | `{artist}/Unknown Album` | 87 | | `--template-file-no-album` / `template_file_no_album` | Template file for the tracks that are not part of an album. | `{title}` | 88 | | `--template-file-playlist` / `template_file_playlist` | Template file for the M3U8 playlist. | `Playlists/{playlist_artist}/{playlist_title}` | 89 | | `--date-tag-template` / `date_tag_template` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` | 90 | | `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` | 91 | | `--truncate` / `truncate` | Maximum length of the file/folder names. | `null` | 92 | | `--download-mode-song` / `download_mode_song` | Download mode for songs. | `ytdlp` | 93 | | `--premium-quality`, `-p` / `premium_quality` | Download songs in premium quality. | `false` | 94 | | `--download-mode-video` / `download_mode_video` | Download mode for videos. | `ytdlp` | 95 | | `--no-config-file`, `-n` / - | Do not use a config file. | `false` | 96 | 97 | 98 | 99 | ### Tag variables 100 | The following variables can be used in the template folder/file and/or in the `exclude_tags` list: 101 | - `album` 102 | - `album_artist` 103 | - `artist` 104 | - `compilation` 105 | - `composer` 106 | - `copyright` 107 | - `cover` 108 | - `disc` 109 | - `disc_total` 110 | - `isrc` 111 | - `label` 112 | - `lyrics` 113 | - `media_type` 114 | - `playlist_artist` 115 | - `playlist_title` 116 | - `playlist_track` 117 | - `producer` 118 | - `rating` 119 | - `release_date` 120 | - `release_year` 121 | - `title` 122 | - `track` 123 | - `track_total` 124 | - `url` 125 | 126 | ### Remux modes 127 | The following remux modes are available: 128 | * `ffmpeg` 129 | * `mp4box` 130 | * Requires mp4decrypt 131 | * Can be obtained from here: https://gpac.wp.imt.fr/downloads 132 | 133 | ### Music videos quality 134 | Music videos will be downloaded in the highest quality available in H.264/AAC, up to 1080p. 135 | 136 | ### Download modes 137 | The following modes are available for songs: 138 | * `ytdlp` 139 | * `aria2c` 140 | * Faster than `ytdlp` 141 | * Can be obtained from here: https://github.com/aria2/aria2/releases 142 | 143 | The following modes are available for videos: 144 | * `ytdlp` 145 | * `nm3u8dlre` 146 | * Faster than `ytdlp` 147 | * Can be obtained from here: https://github.com/nilaoda/N_m3u8DL-RE/releases 148 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "spotify-web-downloader" 3 | description = "A Python CLI app for downloading songs and music videos directly from Spotify." 4 | requires-python = ">=3.8" 5 | authors = [{ name = "glomatico" }] 6 | dependencies = ["click", "pybase62", "pywidevine", "pyyaml", "yt-dlp"] 7 | readme = "README.md" 8 | dynamic = ["version"] 9 | 10 | [project.urls] 11 | repository = "https://github.com/glomatico/spotify-web-downloader" 12 | 13 | [build-system] 14 | requires = ["flit_core"] 15 | build-backend = "flit_core.buildapi" 16 | 17 | [project.scripts] 18 | spotify-web-downloader = "spotify_web_downloader.cli:main" 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click 2 | pybase62 3 | pywidevine 4 | pyyaml 5 | yt-dlp 6 | -------------------------------------------------------------------------------- /spotify_web_downloader/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.8.1" 2 | -------------------------------------------------------------------------------- /spotify_web_downloader/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /spotify_web_downloader/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import json 5 | import logging 6 | import time 7 | from enum import Enum 8 | from pathlib import Path 9 | 10 | import click 11 | 12 | from . import __version__ 13 | from .constants import * 14 | from .downloader import Downloader 15 | from .downloader_music_video import DownloaderMusicVideo 16 | from .downloader_song import DownloaderSong 17 | from .enums import DownloadModeSong, DownloadModeVideo, RemuxMode 18 | from .models import Lyrics 19 | from .spotify_api import SpotifyApi 20 | 21 | spotify_api_sig = inspect.signature(SpotifyApi.__init__) 22 | downloader_sig = inspect.signature(Downloader.__init__) 23 | downloader_song_sig = inspect.signature(DownloaderSong.__init__) 24 | downloader_music_video_sig = inspect.signature(DownloaderMusicVideo.__init__) 25 | 26 | 27 | def get_param_string(param: click.Parameter) -> str: 28 | if isinstance(param.default, Enum): 29 | return param.default.value 30 | elif isinstance(param.default, Path): 31 | return str(param.default) 32 | else: 33 | return param.default 34 | 35 | 36 | def write_default_config_file(ctx: click.Context) -> None: 37 | ctx.params["config_path"].parent.mkdir(parents=True, exist_ok=True) 38 | config_file = { 39 | param.name: get_param_string(param) 40 | for param in ctx.command.params 41 | if param.name not in EXCLUDED_CONFIG_FILE_PARAMS 42 | } 43 | ctx.params["config_path"].write_text(json.dumps(config_file, indent=4)) 44 | 45 | 46 | def load_config_file( 47 | ctx: click.Context, 48 | param: click.Parameter, 49 | no_config_file: bool, 50 | ) -> click.Context: 51 | if no_config_file: 52 | return ctx 53 | if not ctx.params["config_path"].exists(): 54 | write_default_config_file(ctx) 55 | config_file = dict(json.loads(ctx.params["config_path"].read_text())) 56 | for param in ctx.command.params: 57 | if ( 58 | config_file.get(param.name) is not None 59 | and not ctx.get_parameter_source(param.name) 60 | == click.core.ParameterSource.COMMANDLINE 61 | ): 62 | ctx.params[param.name] = param.type_cast_value(ctx, config_file[param.name]) 63 | return ctx 64 | 65 | 66 | @click.command() 67 | @click.help_option("-h", "--help") 68 | @click.version_option(__version__, "-v", "--version") 69 | # CLI specific options 70 | @click.argument( 71 | "urls", 72 | nargs=-1, 73 | type=str, 74 | required=True, 75 | ) 76 | @click.option( 77 | "--wait-interval", 78 | "-w", 79 | type=float, 80 | default=10, 81 | help="Wait interval between downloads in seconds.", 82 | ) 83 | @click.option( 84 | "--download-music-video", 85 | is_flag=True, 86 | help="Attempt to download music videos from songs (can lead to incorrect results).", 87 | ) 88 | @click.option( 89 | "--force-premium", 90 | "-f", 91 | is_flag=True, 92 | help="Force to detect the account as premium.", 93 | ) 94 | @click.option( 95 | "--save-cover", 96 | "-s", 97 | is_flag=True, 98 | help="Save cover as a separate file.", 99 | ) 100 | @click.option( 101 | "--overwrite", 102 | is_flag=True, 103 | help="Overwrite existing files.", 104 | ) 105 | @click.option( 106 | "--read-urls-as-txt", 107 | "-r", 108 | is_flag=True, 109 | help="Interpret URLs as paths to text files containing URLs.", 110 | ) 111 | @click.option( 112 | "--save-playlist", 113 | is_flag=True, 114 | help="Save a M3U8 playlist file when downloading a playlist.", 115 | ) 116 | @click.option( 117 | "--lrc-only", 118 | "-l", 119 | is_flag=True, 120 | help="Download only the synced lyrics.", 121 | ) 122 | @click.option( 123 | "--no-lrc", 124 | is_flag=True, 125 | help="Don't download the synced lyrics.", 126 | ) 127 | @click.option( 128 | "--config-path", 129 | type=Path, 130 | default=Path.home() / ".spotify-web-downloader" / "config.json", 131 | help="Path to config file.", 132 | ) 133 | @click.option( 134 | "--log-level", 135 | type=str, 136 | default="INFO", 137 | help="Log level.", 138 | ) 139 | @click.option( 140 | "--print-exceptions", 141 | is_flag=True, 142 | help="Print exceptions.", 143 | ) 144 | # API specific options 145 | @click.option( 146 | "--cookies-path", 147 | "-c", 148 | type=Path, 149 | default=spotify_api_sig.parameters["cookies_path"].default, 150 | help="Path to .txt cookies file.", 151 | ) 152 | # Downloader specific options 153 | @click.option( 154 | "--output-path", 155 | "-o", 156 | type=Path, 157 | default=downloader_sig.parameters["output_path"].default, 158 | help="Path to output directory.", 159 | ) 160 | @click.option( 161 | "--temp-path", 162 | type=Path, 163 | default=downloader_sig.parameters["temp_path"].default, 164 | help="Path to temporary directory.", 165 | ) 166 | @click.option( 167 | "--wvd-path", 168 | type=Path, 169 | default=downloader_sig.parameters["wvd_path"].default, 170 | help="Path to .wvd file.", 171 | ) 172 | @click.option( 173 | "--ffmpeg-path", 174 | type=str, 175 | default=downloader_sig.parameters["ffmpeg_path"].default, 176 | help="Path to FFmpeg binary.", 177 | ) 178 | @click.option( 179 | "--mp4box-path", 180 | type=str, 181 | default=downloader_sig.parameters["mp4box_path"].default, 182 | help="Path to MP4Box binary.", 183 | ) 184 | @click.option( 185 | "--mp4decrypt-path", 186 | type=str, 187 | default=downloader_sig.parameters["mp4decrypt_path"].default, 188 | help="Path to mp4decrypt binary.", 189 | ) 190 | @click.option( 191 | "--aria2c-path", 192 | type=str, 193 | default=downloader_sig.parameters["aria2c_path"].default, 194 | help="Path to aria2c binary.", 195 | ) 196 | @click.option( 197 | "--nm3u8dlre-path", 198 | type=str, 199 | default=downloader_sig.parameters["nm3u8dlre_path"].default, 200 | help="Path to N_m3u8DL-RE binary.", 201 | ) 202 | @click.option( 203 | "--remux-mode", 204 | type=RemuxMode, 205 | default=downloader_sig.parameters["remux_mode"].default, 206 | help="Remux mode.", 207 | ) 208 | @click.option( 209 | "--template-folder-album", 210 | type=str, 211 | default=downloader_sig.parameters["template_folder_album"].default, 212 | help="Template folder for tracks that are part of an album.", 213 | ) 214 | @click.option( 215 | "--template-folder-compilation", 216 | type=str, 217 | default=downloader_sig.parameters["template_folder_compilation"].default, 218 | help="Template folder for tracks that are part of a compilation album.", 219 | ) 220 | @click.option( 221 | "--template-file-single-disc", 222 | type=str, 223 | default=downloader_sig.parameters["template_file_single_disc"].default, 224 | help="Template file for the tracks that are part of a single-disc album.", 225 | ) 226 | @click.option( 227 | "--template-file-multi-disc", 228 | type=str, 229 | default=downloader_sig.parameters["template_file_multi_disc"].default, 230 | help="Template file for the tracks that are part of a multi-disc album.", 231 | ) 232 | @click.option( 233 | "--template-folder-no-album", 234 | type=str, 235 | default=downloader_sig.parameters["template_folder_no_album"].default, 236 | help="Template folder for the tracks that are not part of an album.", 237 | ) 238 | @click.option( 239 | "--template-file-no-album", 240 | type=str, 241 | default=downloader_sig.parameters["template_file_no_album"].default, 242 | help="Template file for the tracks that are not part of an album.", 243 | ) 244 | @click.option( 245 | "--template-file-playlist", 246 | type=str, 247 | default=downloader_sig.parameters["template_file_playlist"].default, 248 | help="Template file for the M3U8 playlist.", 249 | ) 250 | @click.option( 251 | "--date-tag-template", 252 | type=str, 253 | default=downloader_sig.parameters["date_tag_template"].default, 254 | help="Date tag template.", 255 | ) 256 | @click.option( 257 | "--exclude-tags", 258 | type=str, 259 | default=downloader_sig.parameters["exclude_tags"].default, 260 | help="Comma-separated tags to exclude.", 261 | ) 262 | @click.option( 263 | "--truncate", 264 | type=int, 265 | default=downloader_sig.parameters["truncate"].default, 266 | help="Maximum length of the file/folder names.", 267 | ) 268 | # DownloaderSong specific options 269 | @click.option( 270 | "--download-mode-song", 271 | type=DownloadModeSong, 272 | default=downloader_song_sig.parameters["download_mode"].default, 273 | help="Download mode for songs.", 274 | ) 275 | @click.option( 276 | "--premium-quality", 277 | "-p", 278 | is_flag=True, 279 | default=downloader_song_sig.parameters["premium_quality"].default, 280 | help="Download songs in premium quality.", 281 | ) 282 | # DownloaderMusicVideo specific options 283 | @click.option( 284 | "--download-mode-video", 285 | type=DownloadModeVideo, 286 | default=downloader_music_video_sig.parameters["download_mode"].default, 287 | help="Download mode for videos.", 288 | ) 289 | # This option should always be last 290 | @click.option( 291 | "--no-config-file", 292 | "-n", 293 | is_flag=True, 294 | callback=load_config_file, 295 | help="Do not use a config file.", 296 | ) 297 | def main( 298 | urls: list[str], 299 | wait_interval: float, 300 | download_music_video: bool, 301 | force_premium: bool, 302 | save_cover: bool, 303 | overwrite: bool, 304 | read_urls_as_txt: bool, 305 | save_playlist: bool, 306 | lrc_only: bool, 307 | no_lrc: bool, 308 | config_path: Path, 309 | log_level: str, 310 | print_exceptions: bool, 311 | cookies_path: Path, 312 | output_path: Path, 313 | temp_path: Path, 314 | wvd_path: Path, 315 | ffmpeg_path: str, 316 | mp4box_path: str, 317 | mp4decrypt_path: str, 318 | aria2c_path: str, 319 | nm3u8dlre_path: str, 320 | remux_mode: RemuxMode, 321 | date_tag_template: str, 322 | exclude_tags: str, 323 | truncate: int, 324 | template_folder_album: str, 325 | template_folder_compilation: str, 326 | template_file_single_disc: str, 327 | template_file_multi_disc: str, 328 | template_folder_no_album: str, 329 | template_file_no_album: str, 330 | template_file_playlist: str, 331 | download_mode_song: DownloadModeSong, 332 | premium_quality: bool, 333 | download_mode_video: DownloadModeVideo, 334 | no_config_file: bool, 335 | ) -> None: 336 | logging.basicConfig( 337 | format="[%(levelname)-8s %(asctime)s] %(message)s", 338 | datefmt="%H:%M:%S", 339 | ) 340 | logger = logging.getLogger(__name__) 341 | logger.setLevel(log_level) 342 | logger.debug("Starting downloader") 343 | if not cookies_path.exists(): 344 | logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path)) 345 | return 346 | spotify_api = SpotifyApi(cookies_path) 347 | downloader = Downloader( 348 | spotify_api, 349 | output_path, 350 | temp_path, 351 | wvd_path, 352 | ffmpeg_path, 353 | mp4box_path, 354 | mp4decrypt_path, 355 | aria2c_path, 356 | nm3u8dlre_path, 357 | remux_mode, 358 | template_folder_album, 359 | template_folder_compilation, 360 | template_file_single_disc, 361 | template_file_multi_disc, 362 | template_folder_no_album, 363 | template_file_no_album, 364 | template_file_playlist, 365 | date_tag_template, 366 | exclude_tags, 367 | truncate, 368 | ) 369 | downloader_song = DownloaderSong( 370 | downloader, 371 | download_mode_song, 372 | premium_quality, 373 | ) 374 | downloader_music_video = DownloaderMusicVideo( 375 | downloader, 376 | download_mode_video, 377 | ) 378 | if not lrc_only: 379 | if wvd_path and not wvd_path.exists(): 380 | logger.critical(X_NOT_FOUND_STRING.format(".wvd file", wvd_path)) 381 | return 382 | logger.debug("Setting up CDM") 383 | downloader.set_cdm() 384 | if not downloader.ffmpeg_path_full and remux_mode == RemuxMode.FFMPEG: 385 | logger.critical(X_NOT_FOUND_STRING.format("FFmpeg", ffmpeg_path)) 386 | return 387 | if ( 388 | download_mode_song == DownloadModeSong.ARIA2C 389 | and not downloader.aria2c_path_full 390 | ): 391 | logger.critical(X_NOT_FOUND_STRING.format("aria2c", aria2c_path)) 392 | return 393 | if ( 394 | download_mode_video == DownloadModeVideo.NM3U8DLRE 395 | and not downloader.nm3u8dlre_path_full 396 | ): 397 | logger.critical(X_NOT_FOUND_STRING.format("N_m3u8DL-RE", nm3u8dlre_path)) 398 | return 399 | if remux_mode == RemuxMode.MP4BOX: 400 | if not downloader.mp4box_path_full: 401 | logger.critical(X_NOT_FOUND_STRING.format("MP4Box", mp4box_path)) 402 | return 403 | if not downloader.mp4decrypt_path_full: 404 | logger.critical( 405 | X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path) 406 | ) 407 | return 408 | spotify_api.config_info["isPremium"] = ( 409 | True if force_premium else spotify_api.config_info["isPremium"] 410 | ) 411 | if not spotify_api.config_info["isPremium"] and premium_quality: 412 | logger.critical("Cannot download in premium quality with a free account") 413 | return 414 | if not spotify_api.config_info["isPremium"] and download_music_video: 415 | logger.critical("Cannot download music videos with a free account") 416 | return 417 | error_count = 0 418 | if read_urls_as_txt: 419 | _urls = [] 420 | for url in urls: 421 | if Path(url).exists(): 422 | _urls.extend(Path(url).read_text(encoding="utf-8").splitlines()) 423 | urls = _urls 424 | for url_index, url in enumerate(urls, start=1): 425 | url_progress = f"URL {url_index}/{len(urls)}" 426 | logger.info(f'({url_progress}) Checking "{url}"') 427 | try: 428 | url_info = downloader.get_url_info(url) 429 | download_queue = downloader.get_download_queue(url_info) 430 | except Exception as e: 431 | error_count += 1 432 | logger.error( 433 | f'({url_progress}) Failed to check "{url}"', 434 | exc_info=print_exceptions, 435 | ) 436 | continue 437 | tracks_metadata = download_queue.tracks_metadata 438 | playlist_metadata = download_queue.playlist_metadata 439 | for index, track_metadata in enumerate(tracks_metadata, start=1): 440 | queue_progress = ( 441 | f"Track {index}/{len(tracks_metadata)} from URL {url_index}/{len(urls)}" 442 | ) 443 | try: 444 | logger.info( 445 | f'({queue_progress}) Downloading "{track_metadata["name"]}"' 446 | ) 447 | remuxed_path = None 448 | track_id = track_metadata["id"] 449 | logger.debug("Getting GID metadata") 450 | gid = spotify_api.track_id_to_gid(track_id) 451 | metadata_gid = spotify_api.get_gid_metadata(gid) 452 | if download_music_video and not metadata_gid.get("original_video"): 453 | music_video_id = ( 454 | downloader_music_video.get_music_video_id_from_song_id( 455 | track_id, track_metadata["artists"][0]["id"] 456 | ) 457 | ) 458 | if not music_video_id: 459 | logger.warning( 460 | f"({queue_progress}) No music video alternative found, skipping" 461 | ) 462 | continue 463 | metadata_gid = spotify_api.get_gid_metadata( 464 | spotify_api.track_id_to_gid(music_video_id) 465 | ) 466 | logger.warning( 467 | f"({queue_progress}) Switching to download music video " 468 | f"with title \"{metadata_gid['name']}\"" 469 | ) 470 | if not metadata_gid.get("original_video"): 471 | if metadata_gid.get("has_lyrics"): 472 | logger.debug("Getting lyrics") 473 | lyrics = downloader_song.get_lyrics(track_id) 474 | else: 475 | lyrics = Lyrics() 476 | logger.debug("Getting album metadata") 477 | album_metadata = spotify_api.get_album( 478 | spotify_api.gid_to_track_id(metadata_gid["album"]["gid"]) 479 | ) 480 | logger.debug("Getting track credits") 481 | track_credits = spotify_api.get_track_credits(track_id) 482 | tags = downloader_song.get_tags( 483 | metadata_gid, 484 | album_metadata, 485 | track_credits, 486 | lyrics.unsynced, 487 | ) 488 | if playlist_metadata: 489 | tags = { 490 | **tags, 491 | **downloader.get_playlist_tags( 492 | playlist_metadata, 493 | index, 494 | ), 495 | } 496 | final_path = downloader.get_final_path(tags, ".m4a") 497 | lrc_path = downloader_song.get_lrc_path(final_path) 498 | cover_path = downloader_song.get_cover_path(final_path) 499 | cover_url = downloader.get_cover_url(metadata_gid, "LARGE") 500 | if lrc_only: 501 | pass 502 | elif final_path.exists() and not overwrite: 503 | logger.warning( 504 | f'({queue_progress}) Track already exists at "{final_path}", skipping' 505 | ) 506 | else: 507 | logger.debug("Getting file info") 508 | file_id = downloader_song.get_file_id(metadata_gid) 509 | if not file_id: 510 | logger.error( 511 | f"({queue_progress}) Track not available on Spotify's " 512 | "servers and no alternative found, skipping" 513 | ) 514 | continue 515 | logger.debug("Getting PSSH") 516 | pssh = spotify_api.get_pssh(file_id) 517 | logger.debug("Getting decryption key") 518 | decryption_key = downloader_song.get_decryption_key(pssh) 519 | logger.debug("Getting stream URL") 520 | stream_url = spotify_api.get_stream_url(file_id) 521 | encrypted_path = downloader.get_encrypted_path(track_id, ".m4a") 522 | decrypted_path = downloader.get_decrypted_path(track_id, ".m4a") 523 | logger.debug(f'Downloading to "{encrypted_path}"') 524 | downloader_song.download(encrypted_path, stream_url) 525 | remuxed_path = downloader.get_remuxed_path(track_id, ".m4a") 526 | logger.debug( 527 | f'Decrypting/Remuxing to "{decrypted_path}"/"{remuxed_path}"' 528 | ) 529 | downloader_song.remux( 530 | encrypted_path, 531 | decrypted_path, 532 | remuxed_path, 533 | decryption_key, 534 | ) 535 | if no_lrc or not lyrics.synced: 536 | pass 537 | elif lrc_path.exists() and not overwrite: 538 | logger.debug( 539 | f'Synced lyrics already exists at "{lrc_path}", skipping' 540 | ) 541 | else: 542 | logger.debug(f'Saving synced lyrics to "{lrc_path}"') 543 | downloader_song.save_lrc(lrc_path, lyrics.synced) 544 | elif not spotify_api.config_info["isPremium"]: 545 | logger.error( 546 | f"({queue_progress}) Cannot download music videos with a free account, skipping" 547 | ) 548 | elif lrc_only: 549 | logger.warn( 550 | f"({queue_progress}) Music videos are not downloadable with " 551 | "current settings, skipping" 552 | ) 553 | else: 554 | cover_url = downloader.get_cover_url(metadata_gid, "XXLARGE") 555 | logger.debug("Getting album metadata") 556 | album_metadata = spotify_api.get_album( 557 | spotify_api.gid_to_track_id(metadata_gid["album"]["gid"]) 558 | ) 559 | logger.debug("Getting track credits") 560 | track_credits = spotify_api.get_track_credits(track_id) 561 | tags = downloader_music_video.get_tags( 562 | metadata_gid, 563 | album_metadata, 564 | track_credits, 565 | ) 566 | if playlist_metadata: 567 | tags = { 568 | **tags, 569 | **downloader.get_playlist_tags( 570 | playlist_metadata, 571 | index, 572 | ), 573 | } 574 | final_path = downloader.get_final_path(tags, ".m4v") 575 | cover_path = downloader_music_video.get_cover_path(final_path) 576 | if final_path.exists() and not overwrite: 577 | logger.warning( 578 | f'({queue_progress}) Music video already exists at "{final_path}", skipping' 579 | ) 580 | else: 581 | logger.debug("Getting video manifest") 582 | manifest = downloader_music_video.get_manifest(metadata_gid) 583 | stream_info = downloader_music_video.get_video_stream_info( 584 | manifest 585 | ) 586 | logger.debug("Getting decryption key") 587 | decryption_key = downloader_music_video.get_decryption_key( 588 | stream_info.pssh 589 | ) 590 | m3u8 = downloader_music_video.get_m3u8( 591 | stream_info.base_url, 592 | stream_info.initialization_template_url, 593 | stream_info.segment_template_url, 594 | stream_info.end_time_millis, 595 | stream_info.segment_length, 596 | stream_info.profile_id_video, 597 | stream_info.profile_id_audio, 598 | stream_info.file_type_video, 599 | stream_info.file_type_audio, 600 | ) 601 | m3u8_path_video = downloader_music_video.get_m3u8_path( 602 | track_id, "video" 603 | ) 604 | encrypted_path_video = downloader.get_encrypted_path( 605 | track_id, "_video.ts" 606 | ) 607 | decrypted_path_video = downloader.get_decrypted_path( 608 | track_id, "_video.ts" 609 | ) 610 | logger.debug(f'Downloading video to "{encrypted_path_video}"') 611 | downloader_music_video.save_m3u8(m3u8.video, m3u8_path_video) 612 | downloader_music_video.download( 613 | m3u8_path_video, 614 | encrypted_path_video, 615 | ) 616 | m3u8_path_audio = downloader_music_video.get_m3u8_path( 617 | track_id, "audio" 618 | ) 619 | encrypted_path_audio = downloader.get_encrypted_path( 620 | track_id, "_audio.ts" 621 | ) 622 | decrypted_path_audio = downloader.get_decrypted_path( 623 | track_id, "_audio.ts" 624 | ) 625 | logger.debug(f"Downloading audio to {encrypted_path_audio}") 626 | downloader_music_video.save_m3u8(m3u8.audio, m3u8_path_audio) 627 | downloader_music_video.download( 628 | m3u8_path_audio, 629 | encrypted_path_audio, 630 | ) 631 | remuxed_path = downloader.get_remuxed_path(track_id, ".m4v") 632 | logger.debug( 633 | f'Decrypting video/audio to "{decrypted_path_video}"/"{decrypted_path_audio}" ' 634 | f'and remuxing to "{remuxed_path}"' 635 | ) 636 | downloader_music_video.remux( 637 | decryption_key, 638 | encrypted_path_video, 639 | encrypted_path_audio, 640 | decrypted_path_video, 641 | decrypted_path_audio, 642 | remuxed_path, 643 | ) 644 | if ( 645 | not metadata_gid.get("original_video") and lrc_only 646 | ) or not save_cover: 647 | pass 648 | elif cover_path.exists() and not overwrite: 649 | logger.debug(f'Cover already exists at "{cover_path}", skipping') 650 | elif cover_url is not None: 651 | logger.debug(f'Saving cover to "{cover_path}"') 652 | downloader.save_cover(cover_path, cover_url) 653 | if remuxed_path: 654 | logger.debug("Applying tags") 655 | downloader.apply_tags(remuxed_path, tags, cover_url) 656 | logger.debug(f'Moving to "{final_path}"') 657 | downloader.move_to_final_path(remuxed_path, final_path) 658 | if not lrc_only and save_playlist and playlist_metadata: 659 | playlist_file_path = downloader.get_playlist_file_path(tags) 660 | logger.debug(f'Updating M3U8 playlist from "{playlist_file_path}"') 661 | downloader.update_playlist_file( 662 | playlist_file_path, 663 | final_path, 664 | index, 665 | ) 666 | except Exception as e: 667 | error_count += 1 668 | logger.error( 669 | f'({queue_progress}) Failed to download "{track_metadata["name"]}"', 670 | exc_info=print_exceptions, 671 | ) 672 | finally: 673 | if temp_path.exists(): 674 | logger.debug(f'Cleaning up "{temp_path}"') 675 | downloader.cleanup_temp_path() 676 | if wait_interval > 0 and index != len(tracks_metadata): 677 | logger.debug( 678 | f"Waiting for {wait_interval} second(s) before continuing" 679 | ) 680 | time.sleep(wait_interval) 681 | logger.info(f"Done ({error_count} error(s))") 682 | -------------------------------------------------------------------------------- /spotify_web_downloader/constants.py: -------------------------------------------------------------------------------- 1 | EXCLUDED_CONFIG_FILE_PARAMS = ( 2 | "urls", 3 | "config_path", 4 | "read_urls_as_txt", 5 | "no_config_file", 6 | "version", 7 | "help", 8 | ) 9 | 10 | MP4_TAGS_MAP = { 11 | "album": "\xa9alb", 12 | "album_artist": "aART", 13 | "artist": "\xa9ART", 14 | "composer": "\xa9wrt", 15 | "copyright": "cprt", 16 | "lyrics": "\xa9lyr", 17 | "media_type": "stik", 18 | "producer": "\xa9prd", 19 | "rating": "rtng", 20 | "release_date": "\xa9day", 21 | "title": "\xa9nam", 22 | "url": "\xa9url", 23 | } 24 | 25 | X_NOT_FOUND_STRING = "{} not found at {}" 26 | -------------------------------------------------------------------------------- /spotify_web_downloader/downloader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import functools 5 | import re 6 | import shutil 7 | import subprocess 8 | from pathlib import Path 9 | 10 | import requests 11 | from mutagen.mp4 import MP4, MP4Cover, MP4FreeForm 12 | from pywidevine import Cdm, Device 13 | 14 | from .constants import * 15 | from .enums import RemuxMode 16 | from .models import DownloadQueue, UrlInfo 17 | from .spotify_api import SpotifyApi 18 | from .utils import check_response 19 | 20 | 21 | class Downloader: 22 | ILLEGAL_CHARACTERS_REGEX = r'[\\/:*?"<>|;]' 23 | URL_RE = r"(album|playlist|track)/(\w{22})" 24 | ILLEGAL_CHARACTERS_REPLACEMENT = "_" 25 | 26 | def __init__( 27 | self, 28 | spotify_api: SpotifyApi, 29 | output_path: Path = Path("./Spotify"), 30 | temp_path: Path = Path("./temp"), 31 | wvd_path: Path = Path("./device.wvd"), 32 | ffmpeg_path: str = "ffmpeg", 33 | mp4box_path: str = "MP4Box", 34 | mp4decrypt_path: str = "mp4decrypt", 35 | aria2c_path: str = "aria2c", 36 | nm3u8dlre_path: str = "N_m3u8DL-RE", 37 | remux_mode: RemuxMode = RemuxMode.FFMPEG, 38 | template_folder_album: str = "{album_artist}/{album}", 39 | template_folder_compilation: str = "Compilations/{album}", 40 | template_file_single_disc: str = "{track:02d} {title}", 41 | template_file_multi_disc: str = "{disc}-{track:02d} {title}", 42 | template_folder_no_album: str = "{artist}/Unknown Album", 43 | template_file_no_album: str = "{title}", 44 | template_file_playlist: str = "Playlists/{playlist_artist}/{playlist_title}", 45 | date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ", 46 | exclude_tags: str = None, 47 | truncate: int = None, 48 | silence: bool = False, 49 | ): 50 | self.spotify_api = spotify_api 51 | self.output_path = output_path 52 | self.temp_path = temp_path 53 | self.wvd_path = wvd_path 54 | self.ffmpeg_path = ffmpeg_path 55 | self.mp4box_path = mp4box_path 56 | self.mp4decrypt_path = mp4decrypt_path 57 | self.aria2c_path = aria2c_path 58 | self.nm3u8dlre_path = nm3u8dlre_path 59 | self.remux_mode = remux_mode 60 | self.template_folder_album = template_folder_album 61 | self.template_folder_compilation = template_folder_compilation 62 | self.template_file_single_disc = template_file_single_disc 63 | self.template_file_multi_disc = template_file_multi_disc 64 | self.template_folder_no_album = template_folder_no_album 65 | self.template_file_no_album = template_file_no_album 66 | self.template_file_playlist = template_file_playlist 67 | self.date_tag_template = date_tag_template 68 | self.exclude_tags = exclude_tags 69 | self.truncate = truncate 70 | self.silence = silence 71 | self._set_binaries_full_path() 72 | self._set_exclude_tags_list() 73 | self._set_truncate() 74 | self._set_subprocess_additional_args() 75 | 76 | def _set_binaries_full_path(self): 77 | self.ffmpeg_path_full = shutil.which(self.ffmpeg_path) 78 | self.mp4box_path_full = shutil.which(self.mp4box_path) 79 | self.mp4decrypt_path_full = shutil.which(self.mp4decrypt_path) 80 | self.aria2c_path_full = shutil.which(self.aria2c_path) 81 | self.nm3u8dlre_path_full = shutil.which(self.nm3u8dlre_path) 82 | 83 | def _set_exclude_tags_list(self): 84 | self.exclude_tags_list = ( 85 | [i.lower() for i in self.exclude_tags.split(",")] 86 | if self.exclude_tags is not None 87 | else [] 88 | ) 89 | 90 | def _set_truncate(self): 91 | if self.truncate is not None: 92 | self.truncate = None if self.truncate < 4 else self.truncate 93 | 94 | def _set_subprocess_additional_args(self): 95 | if self.silence: 96 | self.subprocess_additional_args = { 97 | "stdout": subprocess.DEVNULL, 98 | "stderr": subprocess.DEVNULL, 99 | } 100 | else: 101 | self.subprocess_additional_args = {} 102 | 103 | def set_cdm(self) -> None: 104 | self.cdm = Cdm.from_device(Device.load(self.wvd_path)) 105 | 106 | def get_url_info(self, url: str) -> UrlInfo: 107 | url_regex_result = re.search(self.URL_RE, url) 108 | if url_regex_result is None: 109 | raise Exception("Invalid URL") 110 | return UrlInfo(type=url_regex_result.group(1), id=url_regex_result.group(2)) 111 | 112 | def get_download_queue( 113 | self, 114 | url_info: UrlInfo, 115 | ) -> DownloadQueue: 116 | download_queue = DownloadQueue(tracks_metadata=[]) 117 | if url_info.type == "album": 118 | download_queue.tracks_metadata.extend( 119 | track_metadata 120 | for track_metadata in self.spotify_api.get_album(url_info.id)["tracks"][ 121 | "items" 122 | ] 123 | if track_metadata is not None 124 | ) 125 | elif url_info.type == "playlist": 126 | playlist = self.spotify_api.get_playlist(url_info.id) 127 | download_queue.playlist_metadata = playlist.copy() 128 | download_queue.playlist_metadata.pop("tracks") 129 | download_queue.tracks_metadata.extend( 130 | track_metadata["track"] 131 | for track_metadata in playlist["tracks"]["items"] 132 | if track_metadata["track"] is not None 133 | ) 134 | elif url_info.type == "track": 135 | download_queue.tracks_metadata.append( 136 | self.spotify_api.get_track(url_info.id) 137 | ) 138 | return download_queue 139 | 140 | def get_playlist_tags(self, playlist_metadata: dict, playlist_track: int) -> dict: 141 | return { 142 | "playlist_artist": playlist_metadata["owner"]["display_name"], 143 | "playlist_title": playlist_metadata["name"], 144 | "playlist_track": playlist_track, 145 | } 146 | 147 | def get_playlist_file_path( 148 | self, 149 | tags: dict, 150 | ): 151 | template_file = self.template_file_playlist.split("/") 152 | return Path( 153 | self.output_path, 154 | *[ 155 | self.get_sanitized_string(i.format(**tags), True) 156 | for i in template_file[0:-1] 157 | ], 158 | *[ 159 | self.get_sanitized_string(template_file[-1].format(**tags), False) 160 | + ".m3u8" 161 | ], 162 | ) 163 | 164 | def get_final_path(self, tags: dict, file_extension: str) -> Path: 165 | if tags.get("album"): 166 | template_folder = ( 167 | self.template_folder_compilation.split("/") 168 | if tags.get("compilation") 169 | else self.template_folder_album.split("/") 170 | ) 171 | template_file = ( 172 | self.template_file_multi_disc.split("/") 173 | if tags["disc_total"] > 1 174 | else self.template_file_single_disc.split("/") 175 | ) 176 | else: 177 | template_folder = self.template_folder_no_album.split("/") 178 | template_file = self.template_file_no_album.split("/") 179 | template_final = template_folder + template_file 180 | return Path( 181 | self.output_path, 182 | *[ 183 | self.get_sanitized_string(i.format(**tags), True) 184 | for i in template_final[0:-1] 185 | ], 186 | ( 187 | self.get_sanitized_string(template_final[-1].format(**tags), False) 188 | + file_extension 189 | ), 190 | ) 191 | 192 | def update_playlist_file( 193 | self, 194 | playlist_file_path: Path, 195 | final_path: Path, 196 | playlist_track: int, 197 | ): 198 | playlist_file_path.parent.mkdir(parents=True, exist_ok=True) 199 | playlist_file_path_parent_parts_len = len(playlist_file_path.parent.parts) 200 | output_path_parts_len = len(self.output_path.parts) 201 | final_path_relative = Path( 202 | ("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)), 203 | *final_path.parts[output_path_parts_len:], 204 | ) 205 | playlist_file_lines = ( 206 | playlist_file_path.open("r", encoding="utf8").readlines() 207 | if playlist_file_path.exists() 208 | else [] 209 | ) 210 | if len(playlist_file_lines) < playlist_track: 211 | playlist_file_lines.extend( 212 | "\n" for _ in range(playlist_track - len(playlist_file_lines)) 213 | ) 214 | playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n" 215 | with playlist_file_path.open("w", encoding="utf8") as playlist_file: 216 | playlist_file.writelines(playlist_file_lines) 217 | 218 | def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str: 219 | dirty_string = re.sub( 220 | self.ILLEGAL_CHARACTERS_REGEX, 221 | self.ILLEGAL_CHARACTERS_REPLACEMENT, 222 | dirty_string, 223 | ) 224 | if is_folder: 225 | dirty_string = dirty_string[: self.truncate] 226 | if dirty_string.endswith("."): 227 | dirty_string = dirty_string[:-1] + self.ILLEGAL_CHARACTERS_REPLACEMENT 228 | else: 229 | if self.truncate is not None: 230 | dirty_string = dirty_string[: self.truncate - 4] 231 | return dirty_string.strip() 232 | 233 | def get_release_date_datetime_obj(self, metadata_gid: dict) -> datetime.datetime: 234 | metadata_gid_release_date = metadata_gid["album"]["date"] 235 | if metadata_gid_release_date.get("day"): 236 | datetime_obj = datetime.datetime( 237 | year=metadata_gid_release_date["year"], 238 | month=metadata_gid_release_date["month"], 239 | day=metadata_gid_release_date["day"], 240 | ) 241 | elif metadata_gid_release_date.get("month"): 242 | datetime_obj = datetime.datetime( 243 | year=metadata_gid_release_date["year"], 244 | month=metadata_gid_release_date["month"], 245 | day=1, 246 | ) 247 | else: 248 | datetime_obj = datetime.datetime( 249 | year=metadata_gid_release_date["year"], 250 | month=1, 251 | day=1, 252 | ) 253 | return datetime_obj 254 | 255 | def get_release_date_tag(self, datetime_obj: datetime.datetime) -> str: 256 | return datetime_obj.strftime(self.date_tag_template) 257 | 258 | def get_artist(self, artist_list: list[dict]) -> str: 259 | if len(artist_list) == 1: 260 | return artist_list[0]["name"] 261 | return ( 262 | ", ".join(i["name"] for i in artist_list[:-1]) 263 | + f' & {artist_list[-1]["name"]}' 264 | ) 265 | 266 | def get_cover_url(self, metadata_gid: dict, size: str) -> str | None: 267 | if not metadata_gid["album"].get("cover_group"): 268 | return None 269 | return "https://i.scdn.co/image/" + next( 270 | i["file_id"] 271 | for i in metadata_gid["album"]["cover_group"]["image"] 272 | if i["size"] == size 273 | ) 274 | 275 | def get_encrypted_path( 276 | self, 277 | track_id: str, 278 | file_extension: str, 279 | ) -> Path: 280 | return self.temp_path / (f"{track_id}_encrypted" + file_extension) 281 | 282 | def get_decrypted_path( 283 | self, 284 | track_id: str, 285 | file_extension: str, 286 | ) -> Path: 287 | return self.temp_path / (f"{track_id}_decrypted" + file_extension) 288 | 289 | def get_remuxed_path( 290 | self, 291 | track_id: str, 292 | file_extension: str, 293 | ) -> Path: 294 | return self.temp_path / (f"{track_id}_remuxed" + file_extension) 295 | 296 | def decrypt_mp4decrypt( 297 | self, 298 | encrypted_path: Path, 299 | decrypted_path: Path, 300 | decryption_key: str, 301 | ): 302 | subprocess.run( 303 | [ 304 | self.mp4decrypt_path_full, 305 | encrypted_path, 306 | "--key", 307 | f"1:{decryption_key}", 308 | decrypted_path, 309 | ], 310 | check=True, 311 | **self.subprocess_additional_args, 312 | ) 313 | 314 | @staticmethod 315 | @functools.lru_cache() 316 | def get_response_bytes(url: str) -> bytes: 317 | response = requests.get(url) 318 | check_response(response) 319 | return response.content 320 | 321 | def apply_tags(self, fixed_location: Path, tags: dict, cover_url: str): 322 | to_apply_tags = [ 323 | tag_name 324 | for tag_name in tags.keys() 325 | if tag_name not in self.exclude_tags_list 326 | ] 327 | mp4_tags = {} 328 | for tag_name in to_apply_tags: 329 | if tags.get(tag_name) is None: 330 | continue 331 | if tag_name in ("disc", "disc_total"): 332 | if mp4_tags.get("disk") is None: 333 | mp4_tags["disk"] = [[0, 0]] 334 | if tag_name == "disc": 335 | mp4_tags["disk"][0][0] = tags[tag_name] 336 | elif tag_name == "disc_total": 337 | mp4_tags["disk"][0][1] = tags[tag_name] 338 | elif tag_name in ("track", "track_total"): 339 | if mp4_tags.get("trkn") is None: 340 | mp4_tags["trkn"] = [[0, 0]] 341 | if tag_name == "track": 342 | mp4_tags["trkn"][0][0] = tags[tag_name] 343 | elif tag_name == "track_total": 344 | mp4_tags["trkn"][0][1] = tags[tag_name] 345 | elif tag_name == "compilation": 346 | mp4_tags["cpil"] = tags["compilation"] 347 | elif tag_name == "isrc": 348 | mp4_tags["----:com.apple.iTunes:ISRC"] = [ 349 | MP4FreeForm(tags["isrc"].encode("utf-8")) 350 | ] 351 | elif tag_name == "label": 352 | mp4_tags["----:com.apple.iTunes:LABEL"] = [ 353 | MP4FreeForm(tags["label"].encode("utf-8")) 354 | ] 355 | elif MP4_TAGS_MAP.get(tag_name) is not None: 356 | mp4_tags[MP4_TAGS_MAP[tag_name]] = [tags[tag_name]] 357 | if "cover" not in self.exclude_tags_list and cover_url is not None: 358 | mp4_tags["covr"] = [ 359 | MP4Cover( 360 | self.get_response_bytes(cover_url), imageformat=MP4Cover.FORMAT_JPEG 361 | ) 362 | ] 363 | mp4 = MP4(fixed_location) 364 | mp4.clear() 365 | mp4.update(mp4_tags) 366 | mp4.save() 367 | 368 | def move_to_final_path(self, fixed_path: Path, final_path: Path): 369 | final_path.parent.mkdir(parents=True, exist_ok=True) 370 | shutil.move(fixed_path, final_path) 371 | 372 | @functools.lru_cache() 373 | def save_cover(self, cover_path: Path, cover_url: str): 374 | if cover_url is not None: 375 | cover_path.parent.mkdir(parents=True, exist_ok=True) 376 | cover_path.write_bytes(self.get_response_bytes(cover_url)) 377 | 378 | def cleanup_temp_path(self): 379 | shutil.rmtree(self.temp_path) 380 | -------------------------------------------------------------------------------- /spotify_web_downloader/downloader_music_video.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import subprocess 4 | from pathlib import Path 5 | 6 | from pywidevine import PSSH 7 | from yt_dlp import YoutubeDL 8 | 9 | from .downloader import Downloader 10 | from .enums import DownloadModeVideo, RemuxMode 11 | from .models import VideoM3U8, VideoStreamInfo 12 | 13 | 14 | class DownloaderMusicVideo: 15 | M3U8_HEADER = """#EXTM3U 16 | #EXT-X-VERSION:3 17 | #EXT-X-PLAYLIST-TYPE:VOD 18 | #EXT-X-MEDIA-SEQUENCE:0 19 | #EXT-X-TARGETDURATION:1""" 20 | 21 | def __init__( 22 | self, 23 | downloader: Downloader, 24 | download_mode: DownloadModeVideo = DownloadModeVideo.YTDLP, 25 | ): 26 | self.downloader = downloader 27 | self.download_mode = download_mode 28 | 29 | def get_music_video_id_from_song_id( 30 | self, 31 | track_id: str, 32 | artist_id: str, 33 | ) -> dict | None: 34 | now_playing_view = self.downloader.spotify_api.get_now_playing_view( 35 | track_id, artist_id 36 | ) 37 | related_music_videos = now_playing_view["data"]["trackUnion"]["relatedVideos"][ 38 | "items" 39 | ] 40 | if not related_music_videos: 41 | return 42 | return related_music_videos[0]["trackOfVideo"]["data"]["uri"].split(":")[-1] 43 | 44 | def get_manifest(self, metadata_gid: dict) -> dict: 45 | return self.downloader.spotify_api.get_video_manifest( 46 | metadata_gid["original_video"][0]["gid"] 47 | ) 48 | 49 | def get_video_stream_info(self, manifest: dict) -> VideoStreamInfo: 50 | video_formats = list( 51 | format 52 | for format in manifest["contents"][0]["profiles"] 53 | if format.get("video_bitrate") and format["file_type"] == "mp4" 54 | ) 55 | audio_formats = list( 56 | format 57 | for format in manifest["contents"][0]["profiles"] 58 | if format.get("audio_bitrate") and format["file_type"] == "mp4" 59 | ) 60 | best_video_format = max(video_formats, key=lambda x: x["video_bitrate"]) 61 | best_audio_format = max(audio_formats, key=lambda x: x["audio_bitrate"]) 62 | base_url = manifest["base_urls"][0] 63 | initialization_template_url = manifest["initialization_template"] 64 | segment_template_url = manifest["segment_template"] 65 | end_time_millis = manifest["end_time_millis"] 66 | segment_length = manifest["contents"][0]["segment_length"] 67 | profile_id_video = best_video_format["id"] 68 | profile_id_audio = best_audio_format["id"] 69 | file_type_video = best_video_format["file_type"] 70 | file_type_audio = best_audio_format["file_type"] 71 | pssh = next( 72 | encryption_info 73 | for encryption_info in manifest["contents"][0]["encryption_infos"] 74 | if encryption_info["key_system"] == "widevine" 75 | )["encryption_data"] 76 | return VideoStreamInfo( 77 | base_url, 78 | initialization_template_url, 79 | segment_template_url, 80 | end_time_millis, 81 | segment_length, 82 | profile_id_video, 83 | profile_id_audio, 84 | file_type_video, 85 | file_type_audio, 86 | pssh, 87 | ) 88 | 89 | def get_decryption_key(self, pssh: str) -> str: 90 | try: 91 | pssh = PSSH(pssh) 92 | cdm_session = self.downloader.cdm.open() 93 | challenge = self.downloader.cdm.get_license_challenge(cdm_session, pssh) 94 | license = self.downloader.spotify_api.get_widevine_license_video(challenge) 95 | self.downloader.cdm.parse_license(cdm_session, license) 96 | decryption_key = next( 97 | i 98 | for i in self.downloader.cdm.get_keys(cdm_session) 99 | if i.type == "CONTENT" 100 | ).key.hex() 101 | finally: 102 | self.downloader.cdm.close(cdm_session) 103 | return decryption_key 104 | 105 | def get_m3u8_path(self, track_id: str, type: str) -> Path: 106 | return self.downloader.temp_path / f"{track_id}_{type}.m3u8" 107 | 108 | def get_cover_path(self, final_path: Path) -> Path: 109 | return final_path.with_suffix(".jpg") 110 | 111 | def get_m3u8_str(self, segments: list) -> str: 112 | return ( 113 | self.M3U8_HEADER 114 | + "\n" 115 | + "\n".join(f"#EXTINF:1,\n{i}" for i in segments) 116 | + "\n" 117 | + "#EXT-X-ENDLIST" 118 | ) 119 | 120 | def get_m3u8( 121 | self, 122 | base_url: str, 123 | initialization_template_url: str, 124 | segment_template_url: str, 125 | end_time_millis: int, 126 | segment_length: int, 127 | profile_id_video: int, 128 | profile_id_audio: int, 129 | file_type_video: str, 130 | file_type_audio: str, 131 | ) -> VideoM3U8: 132 | segments_video, segments_audio = self.get_segment_urls( 133 | base_url, 134 | initialization_template_url, 135 | segment_template_url, 136 | end_time_millis, 137 | segment_length, 138 | profile_id_video, 139 | file_type_video, 140 | ), self.get_segment_urls( 141 | base_url, 142 | initialization_template_url, 143 | segment_template_url, 144 | end_time_millis, 145 | segment_length, 146 | profile_id_audio, 147 | file_type_audio, 148 | ) 149 | m3u8_video = self.get_m3u8_str(segments_video) 150 | m3u8_audio = self.get_m3u8_str(segments_audio) 151 | return VideoM3U8(m3u8_video, m3u8_audio) 152 | 153 | def get_segment_urls( 154 | self, 155 | base_url: str, 156 | initialization_template_url: str, 157 | segment_template_url: str, 158 | end_time_millis: int, 159 | segment_length: int, 160 | profile_id: int, 161 | file_type: str, 162 | ) -> list[str]: 163 | initialization_template_url_formated = initialization_template_url.replace( 164 | "{{profile_id}}", str(profile_id) 165 | ).replace("{{file_type}}", file_type) 166 | segments = [] 167 | first_segment = base_url + initialization_template_url_formated 168 | segments.append(first_segment) 169 | for i in range(0, int(end_time_millis / 1000) + 1, segment_length): 170 | segment_template_url_formated = ( 171 | segment_template_url.replace("{{profile_id}}", str(profile_id)) 172 | .replace("{{segment_timestamp}}", str(i)) 173 | .replace("{{file_type}}", file_type) 174 | ) 175 | segments.append(base_url + segment_template_url_formated) 176 | return segments 177 | 178 | def save_m3u8(self, m3u8_str: str, m3u8_path: Path) -> None: 179 | m3u8_path.parent.mkdir(parents=True, exist_ok=True) 180 | m3u8_path.write_text(m3u8_str) 181 | 182 | def get_tags( 183 | self, 184 | metadata_gid: dict, 185 | album_metadata: dict, 186 | track_credits: dict, 187 | ) -> dict: 188 | isrc = None 189 | if metadata_gid.get("external_id"): 190 | isrc = next( 191 | (i for i in metadata_gid["external_id"] if i["type"] == "isrc"), None 192 | ) 193 | release_date_datetime_obj = self.downloader.get_release_date_datetime_obj( 194 | metadata_gid 195 | ) 196 | producers = next( 197 | role 198 | for role in track_credits["roleCredits"] 199 | if role["roleTitle"] == "Producers" 200 | )["artists"] 201 | composers = next( 202 | role 203 | for role in track_credits["roleCredits"] 204 | if role["roleTitle"] == "Writers" 205 | )["artists"] 206 | tags = { 207 | "artist": self.downloader.get_artist(metadata_gid["artist"]), 208 | "composer": self.downloader.get_artist(composers) if composers else None, 209 | "copyright": next( 210 | (i["text"] for i in album_metadata["copyrights"] if i["type"] == "P"), 211 | None, 212 | ), 213 | "isrc": isrc.get("id") if isrc is not None else None, 214 | "label": metadata_gid["album"].get("label"), 215 | "media_type": 6, 216 | "producer": self.downloader.get_artist(producers) if producers else None, 217 | "rating": 1 if metadata_gid.get("explicit") else 0, 218 | "title": metadata_gid["name"], 219 | "release_date": self.downloader.get_release_date_tag( 220 | release_date_datetime_obj 221 | ), 222 | "url": f"https://open.spotify.com/track/{self.downloader.spotify_api.gid_to_track_id(metadata_gid['gid'])}", 223 | } 224 | tags["release_year"] = str(release_date_datetime_obj.year) 225 | return tags 226 | 227 | def download(self, m3u8_path: Path, encrypted_path: str): 228 | if self.download_mode == DownloadModeVideo.YTDLP: 229 | self.download_ytdlp(m3u8_path, encrypted_path) 230 | elif self.download_mode == DownloadModeVideo.NM3U8DLRE: 231 | self.download_nm3u8dlre(m3u8_path, encrypted_path) 232 | 233 | def download_ytdlp(self, m3u8_path: Path, encrypted_path: Path) -> None: 234 | with YoutubeDL( 235 | { 236 | "quiet": True, 237 | "no_warnings": True, 238 | "outtmpl": str(encrypted_path), 239 | "allow_unplayable_formats": True, 240 | "fixup": "never", 241 | "allowed_extractors": ["generic"], 242 | "noprogress": self.downloader.silence, 243 | "enable_file_urls": True, 244 | } 245 | ) as ydl: 246 | ydl.download(m3u8_path.resolve().as_uri()) 247 | 248 | def download_nm3u8dlre(self, m3u8_path: Path, encrypted_path: Path) -> None: 249 | encrypted_path.parent.mkdir(parents=True, exist_ok=True) 250 | subprocess.run( 251 | [ 252 | self.downloader.nm3u8dlre_path_full, 253 | m3u8_path, 254 | "--binary-merge", 255 | "--no-log", 256 | "--log-level", 257 | "off", 258 | "--ffmpeg-binary-path", 259 | self.downloader.ffmpeg_path_full, 260 | "--save-name", 261 | encrypted_path.stem, 262 | "--save-dir", 263 | encrypted_path.parent, 264 | "--tmp-dir", 265 | encrypted_path.parent, 266 | ], 267 | check=True, 268 | **self.downloader.subprocess_additional_args, 269 | ) 270 | 271 | def remux( 272 | self, 273 | decryption_key: str, 274 | encrypted_path_video: Path, 275 | encrypted_path_audio: Path, 276 | decrypted_path_video: Path, 277 | decrypted_path_audio: Path, 278 | remuxed_path: Path, 279 | ): 280 | if self.downloader.remux_mode == RemuxMode.FFMPEG: 281 | self.remux_ffmpeg( 282 | decryption_key, 283 | encrypted_path_video, 284 | encrypted_path_audio, 285 | remuxed_path, 286 | ) 287 | elif self.downloader.remux_mode == RemuxMode.MP4BOX: 288 | self.downloader.decrypt_mp4decrypt( 289 | encrypted_path_video, 290 | decrypted_path_video, 291 | decryption_key, 292 | ) 293 | self.downloader.decrypt_mp4decrypt( 294 | encrypted_path_audio, 295 | decrypted_path_audio, 296 | decryption_key, 297 | ) 298 | self.remux_mp4box( 299 | decrypted_path_video, 300 | decrypted_path_audio, 301 | remuxed_path, 302 | ) 303 | 304 | def remux_ffmpeg( 305 | self, 306 | decryption_key: str, 307 | encrypted_path_video: Path, 308 | encrypted_path_audio: Path, 309 | remuxed_path: Path, 310 | ) -> None: 311 | subprocess.run( 312 | [ 313 | self.downloader.ffmpeg_path_full, 314 | "-loglevel", 315 | "error", 316 | "-y", 317 | "-decryption_key", 318 | decryption_key, 319 | "-i", 320 | encrypted_path_video, 321 | "-decryption_key", 322 | decryption_key, 323 | "-i", 324 | encrypted_path_audio, 325 | "-c", 326 | "copy", 327 | "-movflags", 328 | "+faststart", 329 | remuxed_path, 330 | ], 331 | check=True, 332 | **self.downloader.subprocess_additional_args, 333 | ) 334 | 335 | def remux_mp4box( 336 | self, 337 | decrypted_path_video: Path, 338 | decrypted_path_audio: Path, 339 | remuxed_path: Path, 340 | ): 341 | subprocess.run( 342 | [ 343 | self.downloader.mp4box_path_full, 344 | "-quiet", 345 | "-add", 346 | decrypted_path_video, 347 | "-add", 348 | decrypted_path_audio, 349 | "-itags", 350 | "artist=placeholder", 351 | "-keep-utc", 352 | "-new", 353 | remuxed_path, 354 | ], 355 | check=True, 356 | **self.downloader.subprocess_additional_args, 357 | ) 358 | -------------------------------------------------------------------------------- /spotify_web_downloader/downloader_song.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import subprocess 5 | from pathlib import Path 6 | 7 | from pywidevine import PSSH 8 | from yt_dlp import YoutubeDL 9 | 10 | from .downloader import Downloader 11 | from .enums import DownloadModeSong, RemuxMode 12 | from .models import Lyrics 13 | 14 | 15 | class DownloaderSong: 16 | def __init__( 17 | self, 18 | downloader: Downloader, 19 | download_mode: DownloadModeSong = DownloadModeSong.YTDLP, 20 | premium_quality: bool = False, 21 | ): 22 | self.downloader = downloader 23 | self.download_mode = download_mode 24 | self.premium_quality = premium_quality 25 | self._set_codec() 26 | 27 | def _set_codec(self): 28 | self.codec = "MP4_256" if self.premium_quality else "MP4_128" 29 | 30 | def get_decryption_key(self, pssh: str) -> str: 31 | try: 32 | pssh = PSSH(pssh) 33 | cdm_session = self.downloader.cdm.open() 34 | challenge = self.downloader.cdm.get_license_challenge(cdm_session, pssh) 35 | license = self.downloader.spotify_api.get_widevine_license_music(challenge) 36 | self.downloader.cdm.parse_license(cdm_session, license) 37 | decryption_key = next( 38 | i 39 | for i in self.downloader.cdm.get_keys(cdm_session) 40 | if i.type == "CONTENT" 41 | ).key.hex() 42 | finally: 43 | self.downloader.cdm.close(cdm_session) 44 | return decryption_key 45 | 46 | def get_file_id(self, metadata_gid: dict) -> str: 47 | audio_files = metadata_gid.get("file") 48 | if audio_files is None: 49 | if metadata_gid.get("alternative") is not None: 50 | audio_files = metadata_gid["alternative"][0]["file"] 51 | else: 52 | return None 53 | return next(i["file_id"] for i in audio_files if i["format"] == self.codec) 54 | 55 | def get_tags( 56 | self, 57 | metadata_gid: dict, 58 | album_metadata: dict, 59 | track_credits: dict, 60 | lyrics_unsynced: str, 61 | ) -> dict: 62 | isrc = None 63 | if metadata_gid.get("external_id"): 64 | isrc = next( 65 | (i for i in metadata_gid["external_id"] if i["type"] == "isrc"), None 66 | ) 67 | release_date_datetime_obj = self.downloader.get_release_date_datetime_obj( 68 | metadata_gid 69 | ) 70 | producers = next( 71 | role 72 | for role in track_credits["roleCredits"] 73 | if role["roleTitle"] == "Producers" 74 | )["artists"] 75 | composers = next( 76 | role 77 | for role in track_credits["roleCredits"] 78 | if role["roleTitle"] == "Writers" 79 | )["artists"] 80 | tags = { 81 | "album": album_metadata["name"], 82 | "album_artist": self.downloader.get_artist(album_metadata["artists"]), 83 | "artist": self.downloader.get_artist(metadata_gid["artist"]), 84 | "compilation": ( 85 | True if album_metadata["album_type"] == "compilation" else False 86 | ), 87 | "composer": self.downloader.get_artist(composers) if composers else None, 88 | "copyright": next( 89 | (i["text"] for i in album_metadata["copyrights"] if i["type"] == "P"), 90 | None, 91 | ), 92 | "disc": metadata_gid["disc_number"], 93 | "disc_total": album_metadata["tracks"]["items"][-1]["disc_number"], 94 | "isrc": isrc.get("id") if isrc is not None else None, 95 | "label": album_metadata.get("label"), 96 | "lyrics": lyrics_unsynced, 97 | "media_type": 1, 98 | "producer": self.downloader.get_artist(producers) if producers else None, 99 | "rating": 1 if metadata_gid.get("explicit") else 0, 100 | "release_date": self.downloader.get_release_date_tag( 101 | release_date_datetime_obj 102 | ), 103 | "release_year": str(release_date_datetime_obj.year), 104 | "title": metadata_gid["name"], 105 | "track": metadata_gid["number"], 106 | "track_total": max( 107 | i["track_number"] 108 | for i in album_metadata["tracks"]["items"] 109 | if i["disc_number"] == metadata_gid["disc_number"] 110 | ), 111 | "url": f"https://open.spotify.com/track/{self.downloader.spotify_api.gid_to_track_id(metadata_gid['gid'])}", 112 | } 113 | return tags 114 | 115 | def download(self, encrypted_path: Path, stream_url: str): 116 | if self.download_mode == DownloadModeSong.YTDLP: 117 | self.download_ytdlp(encrypted_path, stream_url) 118 | elif self.download_mode == DownloadModeSong.ARIA2C: 119 | self.download_aria2c(encrypted_path, stream_url) 120 | 121 | def download_ytdlp(self, encrypted_path: Path, stream_url: str) -> None: 122 | with YoutubeDL( 123 | { 124 | "quiet": True, 125 | "no_warnings": True, 126 | "outtmpl": str(encrypted_path), 127 | "allow_unplayable_formats": True, 128 | "fixup": "never", 129 | "allowed_extractors": ["generic"], 130 | "noprogress": self.downloader.silence, 131 | } 132 | ) as ydl: 133 | ydl.download(stream_url) 134 | 135 | def download_aria2c(self, encrypted_path: Path, stream_url: str) -> None: 136 | encrypted_path.parent.mkdir(parents=True, exist_ok=True) 137 | subprocess.run( 138 | [ 139 | self.downloader.aria2c_path_full, 140 | "--no-conf", 141 | "--download-result=hide", 142 | "--console-log-level=error", 143 | "--summary-interval=0", 144 | "--file-allocation=none", 145 | stream_url, 146 | "--out", 147 | encrypted_path, 148 | ], 149 | check=True, 150 | **self.downloader.subprocess_additional_args, 151 | ) 152 | print("\r", end="") 153 | 154 | def remux( 155 | self, 156 | encrypted_path: Path, 157 | decrypted_path: Path, 158 | remuxed_path: Path, 159 | decryption_key: str, 160 | ): 161 | if self.downloader.remux_mode == RemuxMode.FFMPEG: 162 | self.remux_ffmpeg(decryption_key, encrypted_path, remuxed_path) 163 | elif self.downloader.remux_mode == RemuxMode.MP4BOX: 164 | self.downloader.decrypt_mp4decrypt( 165 | encrypted_path, decrypted_path, decryption_key 166 | ) 167 | self.remux_mp4box(decrypted_path, remuxed_path) 168 | 169 | def remux_mp4box(self, decrypted_path: Path, remuxed_path: Path): 170 | subprocess.run( 171 | [ 172 | self.downloader.mp4box_path_full, 173 | "-quiet", 174 | "-add", 175 | decrypted_path, 176 | "-itags", 177 | "artist=placeholder", 178 | "-keep-utc", 179 | "-new", 180 | remuxed_path, 181 | ], 182 | check=True, 183 | **self.downloader.subprocess_additional_args, 184 | ) 185 | 186 | def remux_ffmpeg( 187 | self, 188 | decryption_key: str, 189 | encrypted_path: Path, 190 | fixed_path: Path, 191 | ) -> None: 192 | subprocess.run( 193 | [ 194 | self.downloader.ffmpeg_path_full, 195 | "-loglevel", 196 | "error", 197 | "-y", 198 | "-decryption_key", 199 | decryption_key, 200 | "-i", 201 | encrypted_path, 202 | "-movflags", 203 | "+faststart", 204 | "-c", 205 | "copy", 206 | fixed_path, 207 | ], 208 | check=True, 209 | **self.downloader.subprocess_additional_args, 210 | ) 211 | 212 | def get_lyrics_synced_timestamp_lrc(self, time: int) -> str: 213 | lrc_timestamp = datetime.datetime.fromtimestamp( 214 | time / 1000.0, tz=datetime.timezone.utc 215 | ) 216 | return lrc_timestamp.strftime("%M:%S.%f")[:-4] 217 | 218 | def get_lyrics(self, track_id: str) -> Lyrics: 219 | lyrics = Lyrics() 220 | raw_lyrics = self.downloader.spotify_api.get_lyrics(track_id) 221 | if raw_lyrics is None: 222 | return lyrics 223 | lyrics.synced = "" 224 | lyrics.unsynced = "" 225 | for line in raw_lyrics["lyrics"]["lines"]: 226 | if raw_lyrics["lyrics"]["syncType"] == "LINE_SYNCED": 227 | lyrics.synced += f'[{self.get_lyrics_synced_timestamp_lrc(int(line["startTimeMs"]))}]{line["words"]}\n' 228 | lyrics.unsynced += f'{line["words"]}\n' 229 | lyrics.unsynced = lyrics.unsynced[:-1] 230 | return lyrics 231 | 232 | def get_cover_path(self, final_path: Path) -> Path: 233 | return final_path.parent / "Cover.jpg" 234 | 235 | def get_lrc_path(self, final_path: Path) -> Path: 236 | return final_path.with_suffix(".lrc") 237 | 238 | def save_lrc(self, lrc_path: Path, lyrics_synced: str): 239 | if lyrics_synced: 240 | lrc_path.parent.mkdir(parents=True, exist_ok=True) 241 | lrc_path.write_text(lyrics_synced, encoding="utf8") 242 | -------------------------------------------------------------------------------- /spotify_web_downloader/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class RemuxMode(Enum): 5 | FFMPEG = "ffmpeg" 6 | MP4BOX = "mp4box" 7 | 8 | 9 | class DownloadModeSong(Enum): 10 | YTDLP = "ytdlp" 11 | ARIA2C = "aria2c" 12 | 13 | 14 | class DownloadModeVideo(Enum): 15 | YTDLP = "ytdlp" 16 | NM3U8DLRE = "nm3u8dlre" 17 | -------------------------------------------------------------------------------- /spotify_web_downloader/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class Lyrics: 8 | synced: str = None 9 | unsynced: str = None 10 | 11 | 12 | @dataclass 13 | class UrlInfo: 14 | type: str = None 15 | id: str = None 16 | 17 | 18 | @dataclass 19 | class DownloadQueue: 20 | playlist_metadata: dict = None 21 | tracks_metadata: list[dict] = None 22 | 23 | 24 | @dataclass 25 | class VideoStreamInfo: 26 | base_url: str = None 27 | initialization_template_url: str = None 28 | segment_template_url: str = None 29 | end_time_millis: int = None 30 | segment_length: int = None 31 | profile_id_video: int = None 32 | profile_id_audio: int = None 33 | file_type_video: str = None 34 | file_type_audio: str = None 35 | pssh: str = None 36 | 37 | 38 | @dataclass 39 | class VideoM3U8: 40 | video: str = None 41 | audio: str = None 42 | -------------------------------------------------------------------------------- /spotify_web_downloader/spotify_api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import json 5 | import re 6 | import time 7 | import typing 8 | from http.cookiejar import MozillaCookieJar 9 | from pathlib import Path 10 | 11 | import base62 12 | import requests 13 | 14 | from .utils import check_response 15 | 16 | 17 | class SpotifyApi: 18 | SPOTIFY_HOME_PAGE_URL = "https://open.spotify.com/" 19 | CLIENT_VERSION = "1.2.46.25.g7f189073" 20 | GID_METADATA_API_URL = ( 21 | "https://spclient.wg.spotify.com/metadata/4/track/{gid}?market=from_token" 22 | ) 23 | VIDEO_MANIFEST_API_URL = "https://gue1-spclient.spotify.com/manifests/v7/json/sources/{gid}/options/supports_drm" 24 | WIDEVINE_LICENSE_API_URL = ( 25 | "https://gue1-spclient.spotify.com/widevine-license/v1/{type}/license" 26 | ) 27 | LYRICS_API_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/{track_id}" 28 | PSSH_API_URL = "https://seektables.scdn.co/seektable/{file_id}.json" 29 | STREAM_URL_API_URL = ( 30 | "https://gue1-spclient.spotify.com/storage-resolve/v2/files/audio/interactive/11/" 31 | "{file_id}?version=10000000&product=9&platform=39&alt=json" 32 | ) 33 | METADATA_API_URL = "https://api.spotify.com/v1/{type}/{track_id}" 34 | PATHFINDER_API_URL = "https://api-partner.spotify.com/pathfinder/v1/query" 35 | TRACK_CREDITS_API_URL = "https://spclient.wg.spotify.com/track-credits-view/v0/experimental/{track_id}/credits" 36 | EXTEND_TRACK_COLLECTION_WAIT_TIME = 0.5 37 | 38 | def __init__( 39 | self, 40 | cookies_path: Path | None = Path("./cookies.txt"), 41 | ): 42 | self.cookies_path = cookies_path 43 | self._set_session() 44 | 45 | def _set_session(self): 46 | self.session = requests.Session() 47 | if self.cookies_path: 48 | cookies = MozillaCookieJar(self.cookies_path) 49 | cookies.load(ignore_discard=True, ignore_expires=True) 50 | self.session.cookies.update(cookies) 51 | self.session.headers.update( 52 | { 53 | "accept": "application/json", 54 | "accept-language": "en-US", 55 | "content-type": "application/json", 56 | "origin": self.SPOTIFY_HOME_PAGE_URL, 57 | "priority": "u=1, i", 58 | "referer": self.SPOTIFY_HOME_PAGE_URL, 59 | "sec-ch-ua": '"Not)A;Brand";v="99", "Google Chrome";v="127", "Chromium";v="127"', 60 | "sec-ch-ua-mobile": "?0", 61 | "sec-ch-ua-platform": '"Windows"', 62 | "sec-fetch-dest": "empty", 63 | "sec-fetch-mode": "cors", 64 | "sec-fetch-site": "same-site", 65 | "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", 66 | "spotify-app-version": self.CLIENT_VERSION, 67 | "app-platform": "WebPlayer", 68 | } 69 | ) 70 | self._set_session_auth() 71 | 72 | def _set_session_auth(self): 73 | home_page = self.get_home_page() 74 | self.session_info = json.loads( 75 | re.search( 76 | r'', 77 | home_page, 78 | ).group(1) 79 | ) 80 | self.config_info = json.loads( 81 | re.search( 82 | r'', 83 | home_page, 84 | ).group(1) 85 | ) 86 | self.session.headers.update( 87 | { 88 | "Authorization": f"Bearer {self.session_info['accessToken']}", 89 | } 90 | ) 91 | 92 | def _refresh_session_auth(self): 93 | timestamp_session_expire = int( 94 | self.session_info["accessTokenExpirationTimestampMs"] 95 | ) 96 | timestamp_now = time.time() * 1000 97 | if timestamp_now < timestamp_session_expire: 98 | return 99 | self._set_session_auth() 100 | 101 | @staticmethod 102 | def track_id_to_gid(track_id: str) -> str: 103 | return hex(base62.decode(track_id, base62.CHARSET_INVERTED))[2:].zfill(32) 104 | 105 | @staticmethod 106 | def gid_to_track_id(gid: str) -> str: 107 | return base62.encode(int(gid, 16), charset=base62.CHARSET_INVERTED).zfill(22) 108 | 109 | def get_gid_metadata(self, gid: str) -> dict: 110 | self._refresh_session_auth() 111 | response = self.session.get(self.GID_METADATA_API_URL.format(gid=gid)) 112 | check_response(response) 113 | return response.json() 114 | 115 | def get_video_manifest(self, gid: str) -> dict: 116 | self._refresh_session_auth() 117 | response = self.session.get(self.VIDEO_MANIFEST_API_URL.format(gid=gid)) 118 | check_response(response) 119 | return response.json() 120 | 121 | def get_widevine_license_music(self, challenge: bytes) -> bytes: 122 | self._refresh_session_auth() 123 | response = self.session.post( 124 | self.WIDEVINE_LICENSE_API_URL.format(type="audio"), 125 | challenge, 126 | ) 127 | check_response(response) 128 | return response.content 129 | 130 | def get_widevine_license_video(self, challenge: bytes) -> bytes: 131 | self._refresh_session_auth() 132 | response = self.session.post( 133 | self.WIDEVINE_LICENSE_API_URL.format(type="video"), 134 | challenge, 135 | ) 136 | check_response(response) 137 | return response.content 138 | 139 | def get_lyrics(self, track_id: str) -> dict | None: 140 | self._refresh_session_auth() 141 | response = self.session.get(self.LYRICS_API_URL.format(track_id=track_id)) 142 | if response.status_code == 404: 143 | return None 144 | check_response(response) 145 | return response.json() 146 | 147 | def get_pssh(self, file_id: str) -> str: 148 | response = requests.get(self.PSSH_API_URL.format(file_id=file_id)) 149 | check_response(response) 150 | return response.json()["pssh"] 151 | 152 | def get_stream_url(self, file_id: str) -> str: 153 | self._refresh_session_auth() 154 | response = self.session.get(self.STREAM_URL_API_URL.format(file_id=file_id)) 155 | check_response(response) 156 | return response.json()["cdnurl"][0] 157 | 158 | def get_track(self, track_id: str) -> dict: 159 | self._refresh_session_auth() 160 | response = self.session.get( 161 | self.METADATA_API_URL.format(type="tracks", track_id=track_id) 162 | ) 163 | check_response(response) 164 | return response.json() 165 | 166 | def extend_track_collection( 167 | self, 168 | track_collection: dict, 169 | ) -> typing.Generator[dict, None, None]: 170 | next_url = track_collection["tracks"]["next"] 171 | while next_url is not None: 172 | response = self.session.get(next_url) 173 | check_response(response) 174 | extended_collection = response.json() 175 | yield extended_collection 176 | next_url = extended_collection["next"] 177 | time.sleep(self.EXTEND_TRACK_COLLECTION_WAIT_TIME) 178 | 179 | @functools.lru_cache() 180 | def get_album( 181 | self, 182 | album_id: str, 183 | extend: bool = True, 184 | ) -> dict: 185 | self._refresh_session_auth() 186 | response = self.session.get( 187 | self.METADATA_API_URL.format(type="albums", track_id=album_id) 188 | ) 189 | check_response(response) 190 | album = response.json() 191 | if extend: 192 | album["tracks"]["items"].extend( 193 | [ 194 | item 195 | for extended_collection in self.extend_track_collection(album) 196 | for item in extended_collection["items"] 197 | ] 198 | ) 199 | return album 200 | 201 | def get_playlist( 202 | self, 203 | playlist_id: str, 204 | extend: bool = True, 205 | ) -> dict: 206 | self._refresh_session_auth() 207 | response = self.session.get( 208 | self.METADATA_API_URL.format(type="playlists", track_id=playlist_id) 209 | ) 210 | check_response(response) 211 | playlist = response.json() 212 | if extend: 213 | playlist["tracks"]["items"].extend( 214 | [ 215 | item 216 | for extended_collection in self.extend_track_collection(playlist) 217 | for item in extended_collection["items"] 218 | ] 219 | ) 220 | return playlist 221 | 222 | def get_now_playing_view(self, track_id: str, artist_id: str) -> dict: 223 | self._refresh_session_auth() 224 | response = self.session.get( 225 | self.PATHFINDER_API_URL, 226 | params={ 227 | "operationName": "queryNpvArtist", 228 | "variables": json.dumps( 229 | { 230 | "artistUri": f"spotify:artist:{artist_id}", 231 | "trackUri": f"spotify:track:{track_id}", 232 | "enableCredits": True, 233 | "enableRelatedVideos": True, 234 | } 235 | ), 236 | "extensions": json.dumps( 237 | { 238 | "persistedQuery": { 239 | "version": 1, 240 | "sha256Hash": "4ec4ae302c609a517cab6b8868f601cd3457c751c570ab12e988723cc036284f", 241 | } 242 | } 243 | ), 244 | }, 245 | ) 246 | check_response(response) 247 | return response.json() 248 | 249 | def get_track_credits(self, track_id: str) -> dict: 250 | self._refresh_session_auth() 251 | response = self.session.get( 252 | self.TRACK_CREDITS_API_URL.format(track_id=track_id) 253 | ) 254 | check_response(response) 255 | return response.json() 256 | 257 | def get_home_page(self) -> str: 258 | response = self.session.get( 259 | SpotifyApi.SPOTIFY_HOME_PAGE_URL, 260 | ) 261 | check_response(response) 262 | return response.text 263 | -------------------------------------------------------------------------------- /spotify_web_downloader/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import requests 4 | 5 | 6 | def check_response(response: requests.Response): 7 | try: 8 | response.raise_for_status() 9 | except requests.HTTPError: 10 | _raise_response_exception(response) 11 | 12 | 13 | def _raise_response_exception(response: requests.Response): 14 | raise Exception( 15 | f"Request failed with status code {response.status_code}: {response.text}" 16 | ) 17 | --------------------------------------------------------------------------------