From 4de703a639c6057cbbd85b2436a0af5af47349d1 Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Sun, 29 Sep 2024 20:56:27 -0300 Subject: [PATCH 01/17] add `isAnonymous` check --- votify/cli.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/votify/cli.py b/votify/cli.py index f6abca2..9deca11 100644 --- a/votify/cli.py +++ b/votify/cli.py @@ -390,6 +390,11 @@ def main( logger.setLevel(log_level) logger.info("Starting Votify") spotify_api = SpotifyApi(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, From 2d5b3b8c8da21cdd822474283b032f05909072f9 Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Sun, 29 Sep 2024 20:58:51 -0300 Subject: [PATCH 02/17] bump version --- votify/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/votify/__init__.py b/votify/__init__.py index bf25615..daa50c7 100644 --- a/votify/__init__.py +++ b/votify/__init__.py @@ -1 +1 @@ -__version__ = "1.4.1" +__version__ = "1.4.2" From 50bca7edb00a438335d908901d6cabf30b63167f Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Fri, 11 Oct 2024 17:33:54 -0300 Subject: [PATCH 03/17] Update README.md --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index cad9a45..5238a55 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,16 @@ The following tools are optional but required for specific features. Add them to votify [OPTIONS] URLS... ``` +### URL support +Votify supports the following types of URLs: +* Song +* Album +* Playlist +* Podcast episode +* Podcast series +* Music video +* Artist + ### Examples * Download a song ```bash From de72f46912e6293c8e9e6a54c29d6457f5affb10 Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Fri, 11 Oct 2024 17:35:13 -0300 Subject: [PATCH 04/17] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5238a55..e31d62e 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The following tools are optional but required for specific features. Add them to votify [OPTIONS] URLS... ``` -### URL support +### Supported URL types Votify supports the following types of URLs: * Song * Album From b2d40b5b7a31a6b8b48a17ce16ce92128ebcb8d1 Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Sun, 20 Oct 2024 19:12:11 -0300 Subject: [PATCH 05/17] create constructors `from_file` and `from_browser` --- votify/spotify_api.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/votify/spotify_api.py b/votify/spotify_api.py index 265eddd..bc229bf 100644 --- a/votify/spotify_api.py +++ b/votify/spotify_api.py @@ -5,11 +5,12 @@ 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 import requests +import rookiepy from .utils import check_response @@ -38,17 +39,26 @@ 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_file(cls, cookies_path: Path): + cookies = MozillaCookieJar(cookies_path) + cookies.load(ignore_discard=True, ignore_expires=True) + return cls(cookies) + + @classmethod + def from_browser(cls, browser: str): + cookies: rookiepy.CookieList = getattr(rookiepy, browser)(["spotify.com"]) + return cls(rookiepy.to_cookiejar(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", From e8167f0e3764d3bb1a093d5986c4ee7abce57593 Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Sun, 20 Oct 2024 19:12:21 -0300 Subject: [PATCH 06/17] add `cookies_browser` option --- votify/cli.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/votify/cli.py b/votify/cli.py index 9deca11..ccf5c2e 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,12 @@ def main( ) logger.setLevel(log_level) logger.info("Starting Votify") - spotify_api = SpotifyApi(cookies_path) + if cookies_browser: + spotify_api = SpotifyApi.from_browser(cookies_browser) + else: + if not cookies_path.exists(): + logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path)) + spotify_api = SpotifyApi.from_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" From 5eb6d8de28968daa524d21e1e0127d56bffe233a Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Mon, 21 Oct 2024 07:07:51 -0300 Subject: [PATCH 07/17] exit when cookies file doesnt exist --- votify/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/votify/cli.py b/votify/cli.py index ccf5c2e..d3da182 100644 --- a/votify/cli.py +++ b/votify/cli.py @@ -400,6 +400,7 @@ def main( else: if not cookies_path.exists(): logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path)) + return spotify_api = SpotifyApi.from_file(cookies_path) if spotify_api.config_info["isAnonymous"]: logger.critical( From 9db8678f37f83437821ce503f5d471c4e7f3c0b1 Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Thu, 31 Oct 2024 12:46:29 -0300 Subject: [PATCH 08/17] remove rookiepy --- votify/cli.py | 10 +++------- votify/spotify_api.py | 8 +------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/votify/cli.py b/votify/cli.py index d3da182..ed976f9 100644 --- a/votify/cli.py +++ b/votify/cli.py @@ -395,13 +395,9 @@ def main( ) logger.setLevel(log_level) logger.info("Starting Votify") - if cookies_browser: - spotify_api = SpotifyApi.from_browser(cookies_browser) - else: - if not cookies_path.exists(): - logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path)) - return - spotify_api = SpotifyApi.from_file(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" diff --git a/votify/spotify_api.py b/votify/spotify_api.py index bc229bf..db431e2 100644 --- a/votify/spotify_api.py +++ b/votify/spotify_api.py @@ -10,7 +10,6 @@ from pathlib import Path import base62 import requests -import rookiepy from .utils import check_response @@ -45,16 +44,11 @@ class SpotifyApi: self._set_session() @classmethod - def from_file(cls, cookies_path: Path): + def from_cookies_file(cls, cookies_path: Path): cookies = MozillaCookieJar(cookies_path) cookies.load(ignore_discard=True, ignore_expires=True) return cls(cookies) - @classmethod - def from_browser(cls, browser: str): - cookies: rookiepy.CookieList = getattr(rookiepy, browser)(["spotify.com"]) - return cls(rookiepy.to_cookiejar(cookies)) - def _set_session(self): self.session = requests.Session() if self.cookies is not None: From 8e799265555a29ad80f6712037d9acfdbe3d945e Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Thu, 31 Oct 2024 12:55:17 -0300 Subject: [PATCH 09/17] fix spotify api loading --- votify/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/votify/cli.py b/votify/cli.py index ed976f9..e918549 100644 --- a/votify/cli.py +++ b/votify/cli.py @@ -397,7 +397,7 @@ def main( logger.info("Starting Votify") if not cookies_path.exists(): logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path)) - spotify_api = SpotifyApi.from_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" From 86639d63f213a40a16f77ddffa1e96fe9a1e5b88 Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Thu, 31 Oct 2024 12:55:39 -0300 Subject: [PATCH 10/17] hardcode podcast decryption key lol --- votify/downloader_audio.py | 4 ++++ votify/downloader_episode.py | 5 ++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/votify/downloader_audio.py b/votify/downloader_audio.py index 6912ca6..e80b263 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..40e951a 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,7 @@ 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 encrypted_path = self.downloader.get_file_temp_path( episode_id, "_encrypted", From 9d69b12a2555a38cb5f0bd9ff235ac3a151b4156 Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Thu, 31 Oct 2024 13:34:59 -0300 Subject: [PATCH 11/17] fix decryption key parsing --- votify/downloader_episode.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/votify/downloader_episode.py b/votify/downloader_episode.py index 40e951a..02a6073 100644 --- a/votify/downloader_episode.py +++ b/votify/downloader_episode.py @@ -120,7 +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: - decryption_key = self.DEFAULT_EPISODE_DECRYPTION_KEY + 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", From 92c00ea2b0dc96bb91c2880101589812bc636062 Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Thu, 31 Oct 2024 13:35:09 -0300 Subject: [PATCH 12/17] fix shaka packager --- votify/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/votify/cli.py b/votify/cli.py index e918549..b2eae97 100644 --- a/votify/cli.py +++ b/votify/cli.py @@ -514,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) ) From 6b0f64fdf0744bff9fad9bef8ffe673b2286d960 Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Thu, 31 Oct 2024 14:13:38 -0300 Subject: [PATCH 13/17] small optimization --- votify/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/votify/cli.py b/votify/cli.py index b2eae97..52656cd 100644 --- a/votify/cli.py +++ b/votify/cli.py @@ -483,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" From c0543791e9565762cc7fb1122fa5d656382c43eb Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Thu, 31 Oct 2024 14:15:23 -0300 Subject: [PATCH 14/17] Update README.md --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e31d62e..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 From 57e09d6c86626a0a877262eb592a100935647163 Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Thu, 31 Oct 2024 14:31:17 -0300 Subject: [PATCH 15/17] bump version --- votify/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/votify/__init__.py b/votify/__init__.py index daa50c7..aa56ed4 100644 --- a/votify/__init__.py +++ b/votify/__init__.py @@ -1 +1 @@ -__version__ = "1.4.2" +__version__ = "1.4.3" From 928be9c817eba0286966fe03304466cf4f6036b7 Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Wed, 6 Nov 2024 11:20:30 -0300 Subject: [PATCH 16/17] support for encrypyed podcast videos --- votify/downloader_episode_video.py | 45 ++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 6 deletions(-) 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, From b2352d5a722622344d807a3754e76db94c31660b Mon Sep 17 00:00:00 2001 From: Rafael Moraes <50295204+glomatico@users.noreply.github.com> Date: Wed, 6 Nov 2024 11:20:43 -0300 Subject: [PATCH 17/17] bump version --- votify/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/votify/__init__.py b/votify/__init__.py index aa56ed4..c0f285b 100644 --- a/votify/__init__.py +++ b/votify/__init__.py @@ -1 +1 @@ -__version__ = "1.4.3" +__version__ = "1.4.4"