mirror of
https://github.com/glomatico/votify.git
synced 2026-10-14 03:42:36 +02:00
Merge branch 'main' of https://github.com/es3n1n/votify
This commit is contained in:
@@ -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
@@ -1 +1 @@
|
||||
__version__ = "1.4.1"
|
||||
__version__ = "1.4.4"
|
||||
|
||||
+19
-6
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user