diff --git a/README.md b/README.md index cad9a45..cfda426 100644 --- a/README.md +++ b/README.md @@ -21,20 +21,18 @@ A Python CLI app for downloading songs/podcasts/videos from Spotify. ### Optional dependencies The following tools are optional but required for specific features. Add them to your system’s PATH or specify their paths using command-line arguments or the config file. * [FFmpeg](https://ffmpeg.org/download.html) - * Used when setting `ffmpeg` as remux mode. - * Used when setting `mp4` or `webm` as video format. + * Used when setting `ffmpeg` as remux mode and when setting `mp4` or `webm` as video format. * [MP4Box](https://gpac.io/downloads/gpac-nightly-builds/) * Used when setting `mp4box` as remux mode. * [Shaka Packager](https://github.com/shaka-project/shaka-packager/releases/latest) - * Used when setting `webm` as video format and when downloading music videos. + * Used when setting `webm` as video format and for downloading music videos. * [mp4decrypt](https://www.bento4.com/downloads/) * Used when setting `mp4box` or `mp4decrypt` as remux mode. * [aria2c](https://github.com/aria2/aria2/releases) * Used when setting `aria2c` as download mode. * .wvd file * Used when setting `aac` as audio quality or when downloading music videos. - * A .wvd file contains the Widevine keys from a device and is required to decrypt music videos and songs in AAC. 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. - * Note: .wvd files extracted from emulated devices may not work. + * A .wvd file contains the Widevine keys from a device and is required to decrypt music videos and songs in AAC. 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. **Note:** .wvd files extracted from emulated devices may not work. ## Installation 1. Install the package `votify` using pip @@ -49,6 +47,16 @@ The following tools are optional but required for specific features. Add them to votify [OPTIONS] URLS... ``` +### Supported URL types +Votify supports the following types of URLs: +* Song +* Album +* Playlist +* Podcast episode +* Podcast series +* Music video +* Artist + ### Examples * Download a song ```bash diff --git a/votify/__init__.py b/votify/__init__.py index bf25615..c0f285b 100644 --- a/votify/__init__.py +++ b/votify/__init__.py @@ -1 +1 @@ -__version__ = "1.4.1" +__version__ = "1.4.4" diff --git a/votify/cli.py b/votify/cli.py index f6abca2..52656cd 100644 --- a/votify/cli.py +++ b/votify/cli.py @@ -141,13 +141,18 @@ def load_config_file( is_flag=True, help="Don't print exceptions.", ) -# SpotifyApi specific options @click.option( "--cookies-path", type=Path, - default=spotify_api_sig.parameters["cookies_path"].default, + default=Path("./cookies.txt"), help="Path to cookies file.", ) +@click.option( + "--cookies-browser", + type=str, + default=None, + help="Browser to use to get cookies instead of loading a file.", +) # Downloader specific options @click.option( "--output-path", @@ -351,6 +356,7 @@ def main( log_level: str, no_exceptions: bool, cookies_path: Path, + cookies_browser: str, output_path: Path, temp_path: Path, wvd_path: Path, @@ -389,7 +395,14 @@ def main( ) logger.setLevel(log_level) logger.info("Starting Votify") - spotify_api = SpotifyApi(cookies_path) + if not cookies_path.exists(): + logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path)) + spotify_api = SpotifyApi.from_cookies_file(cookies_path) + if spotify_api.config_info["isAnonymous"]: + logger.critical( + "Failed to get a valid session. Try logging in and exporting your cookies again" + ) + return downloader = Downloader( spotify_api, output_path, @@ -470,7 +483,7 @@ def main( X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path) ) return - if not wvd_path.exists() and audio_quality in AAC_AUDIO_QUALITIES: + if not wvd_path.exists(): logger.critical( X_NOT_FOUND_STRING.format(".wvd", wvd_path) + ", a .wvd file is required for downloading in AAC quality" @@ -501,11 +514,11 @@ def main( logger.critical(X_NOT_FOUND_STRING.format("MP4Box", mp4box_path)) return music_video_warning_message = [] - if not downloader.mp4decrypt_path_full and video_format != VideoFormat.WEBM: + if not downloader.mp4decrypt_path_full and video_format == VideoFormat.MP4: music_video_warning_message.append( X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path) ) - elif not downloader.packager_path_full: + elif not downloader.packager_path_full and video_format == VideoFormat.WEBM: music_video_warning_message.append( X_NOT_FOUND_STRING.format("Shaka Packager", packager_path) ) diff --git a/votify/downloader_audio.py b/votify/downloader_audio.py index 7ba818c..553b9b6 100644 --- a/votify/downloader_audio.py +++ b/votify/downloader_audio.py @@ -18,6 +18,10 @@ from .models import StreamInfoAudio class DownloaderAudio: + DEFAULT_EPISODE_DECRYPTION_KEY = ( + b"\xde\xad\xbe\xef\xde\xad\xbe\xef\xde\xad\xbe\xef\xde\xad\xbe\xef" # lmao wtf + ) + def __init__( self, downloader: Downloader, diff --git a/votify/downloader_episode.py b/votify/downloader_episode.py index 8fc669e..02a6073 100644 --- a/votify/downloader_episode.py +++ b/votify/downloader_episode.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging from pathlib import Path +from .constants import AAC_AUDIO_QUALITIES from .downloader_audio import DownloaderAudio from .models import StreamInfoAudio @@ -119,9 +120,11 @@ class DownloaderEpisode(DownloaderAudio): if final_path.exists() and not self.downloader.overwrite: logger.warning(f'Track already exists at "{final_path}", skipping') else: - if not decryption_key: - logger.debug("Getting decryption key") - decryption_key = self.get_decryption_key(stream_info) + decryption_key = ( + self.DEFAULT_EPISODE_DECRYPTION_KEY.hex() + if self.audio_quality in AAC_AUDIO_QUALITIES + else self.DEFAULT_EPISODE_DECRYPTION_KEY + ) encrypted_path = self.downloader.get_file_temp_path( episode_id, "_encrypted", diff --git a/votify/downloader_episode_video.py b/votify/downloader_episode_video.py index 005f307..02df4ad 100644 --- a/votify/downloader_episode_video.py +++ b/votify/downloader_episode_video.py @@ -57,6 +57,15 @@ class DownloaderEpisodeVideo(DownloaderVideo): logger.warning("Episode has no video, skipping") return stream_info = self.get_stream_info(video_gid) + if ( + stream_info.encryption_data_widevine + and not self.downloader.wvd_path.exists() + ): + logger.warning( + "Podcast video has Widevine encryption, but no .wvd file was found at " + f'"{self.downloader.wvd_path}", skipping' + ) + return tags = self.downloader_episode.get_tags( episode_metadata, show_metadata, @@ -77,14 +86,32 @@ class DownloaderEpisodeVideo(DownloaderVideo): logger.warning(f'Episode already exists at "{final_path}", skipping') return else: - temp_path_video = self.downloader.get_file_temp_path( + key_id, decryption_key = ( + self.downloader.get_widevine_decryption_key( + stream_info.encryption_data_widevine, + "video", + ) + if stream_info.encryption_data_widevine + else (None, None) + ) + encrypted_path_video = self.downloader.get_file_temp_path( episode_id, - "_video", + "_video_encrypted", file_extension, ) - temp_path_audio = self.downloader.get_file_temp_path( + encrypted_path_audio = self.downloader.get_file_temp_path( episode_id, - "_audio", + "_audio_encrypted", + file_extension, + ) + decrypted_path_video = self.downloader.get_file_temp_path( + episode_id, + "_video_decrypted", + file_extension, + ) + decrypted_path_audio = self.downloader.get_file_temp_path( + episode_id, + "_audio_decrypted", file_extension, ) remuxed_path = self.downloader.get_file_temp_path( @@ -92,15 +119,21 @@ class DownloaderEpisodeVideo(DownloaderVideo): "_remuxed", file_extension, ) + temp_path_video = encrypted_path_video if key_id else decrypted_path_video + temp_path_audio = encrypted_path_audio if key_id else decrypted_path_audio logger.debug(f'Downloading video to "{temp_path_video}"') self.download_segments(stream_info.segment_urls_video, temp_path_video) logger.debug(f'Downloading audio to "{temp_path_audio}"') self.download_segments(stream_info.segment_urls_audio, temp_path_audio) logger.debug(f'Remuxing to "{remuxed_path}"') self.remux( - temp_path_video, - temp_path_audio, + decrypted_path_video, + decrypted_path_audio, remuxed_path, + key_id, + decryption_key, + encrypted_path_video, + encrypted_path_audio, ) self.downloader._final_processing( cover_path, diff --git a/votify/spotify_api.py b/votify/spotify_api.py index 265eddd..db431e2 100644 --- a/votify/spotify_api.py +++ b/votify/spotify_api.py @@ -5,7 +5,7 @@ import json import re import time import typing -from http.cookiejar import MozillaCookieJar +from http.cookiejar import CookieJar, MozillaCookieJar from pathlib import Path import base62 @@ -38,17 +38,21 @@ class SpotifyApi: def __init__( self, - cookies_path: Path | None = Path("./cookies.txt"), + cookies: CookieJar = None, ): - self.cookies_path = cookies_path + self.cookies = cookies self._set_session() + @classmethod + def from_cookies_file(cls, cookies_path: Path): + cookies = MozillaCookieJar(cookies_path) + cookies.load(ignore_discard=True, ignore_expires=True) + return cls(cookies) + def _set_session(self): self.session = requests.Session() - if self.cookies_path: - cookies = MozillaCookieJar(self.cookies_path) - cookies.load(ignore_discard=True, ignore_expires=True) - self.session.cookies.update(cookies) + if self.cookies is not None: + self.session.cookies.update(self.cookies) self.session.headers.update( { "accept": "application/json",