This commit is contained in:
Arsenii es3n1n
2024-11-13 13:05:02 +01:00
7 changed files with 93 additions and 28 deletions
+13 -5
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
__version__ = "1.4.1"
__version__ = "1.4.4"
+19 -6
View File
@@ -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)
)
+4
View File
@@ -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,
+6 -3
View File
@@ -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",
+39 -6
View File
@@ -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,
+11 -7
View File
@@ -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",