Merge pull request #1 from glomatico/main-2

Main 2
This commit is contained in:
Rafael Moraes
2024-09-25 11:38:26 -03:00
committed by GitHub
16 changed files with 1759 additions and 304 deletions
+99 -33
View File
@@ -1,10 +1,12 @@
# Votify
A Python CLI app for downloading songs/podcasts from Spotify.
A Python CLI app for downloading songs/podcasts/videos from Spotify.
**Discord Server:** https://discord.gg/aBjMEZ9tnq
## Features
* Download songs and podcasts in Vorbis 96/128kbps or in 320kbps with a premium account
* Download songs and podcasts in Vorbis or AAC
* Download podcast videos
* Download music videos with a premium account
* Support for artist links to download all of their albums
* Download synced lyrics
* Highly configurable
@@ -12,21 +14,35 @@ A Python CLI app for downloading songs/podcasts from Spotify.
## Prerequisites
* Python 3.8 or higher
* The cookies file of your Spotify browser session in Netscape format (free or premium)
* 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:
* To export your cookies, use one of the following browser extensions while signed in to Spotify:
* Firefox: https://addons.mozilla.org/addon/export-cookies-txt
* Chromium based browsers: https://chrome.google.com/webstore/detail/gdocmgbfkjnnpapoeobnolbbkoibbcif
* Unplayplay
* Build it from source: https://git.gay/glomatico/unplayplay.
### 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.
* [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.
* [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.
## Installation
1. Install the package `votify` using pip
```bash
pip install votify
```
2. Set up the `cookies.txt`.
* You can either move to the current directory from which you will be running Votify or specify its path using the command line arguments/config file.
3. Set up Unplayplay
* You can either add it to your PATH or specify its path using the command line arguments/config file.
2. Set up the cookies file.
* You can either move to the current directory from which you will be running Votify as `cookies.txt` or specify its path using the command-line arguments/config file.
## Usage
```bash
@@ -42,6 +58,25 @@ votify [OPTIONS] URLS...
```bash
votify "https://open.spotify.com/album/0r8D5N674HbTXlR3zNxeU1"
```
* Download a podcast episode
```bash
votify "https://open.spotify.com/episode/3kwxWnzGH8T6UY2Nq582zx"
```
* Download a podcast series
```bash
votify "https://open.spotify.com/show/4rOoJ6Egrf8K2IrywzwOMk"
* Download a music video
```bash
votify "https://open.spotify.com/track/31k4hgHmrbzorLZMvMWuzq" --enable-videos
```
* List and select a related music video to download from a song
```bash
votify "https://open.spotify.com/track/0a0n6u6j3t6m0p4k0t0k0u0" --enable-videos --download-music-videos
```
* Download a podcast video
```bash
votify "https://open.spotify.com/episode/3kwxWnzGH8T6UY2Nq582zx" --enable-videos --download-podcast-videos
```
* Choose which albums to download from an artist
```bash
votify "https://open.spotify.com/artist/0gxyHStUsqpMadRV0Di1Qt"
@@ -54,46 +89,56 @@ votify [OPTIONS] URLS...
* Enter - Confirm selection
## Configuration
Votify can be configured using the command line arguments or the config file.
Votify can be configured using the command-line arguments or the config file.
The config file is created automatically when you run Votify for the first time at `~/.votify/config.json` on Linux and `%USERPROFILE%\.votify\config.json` on Windows.
Config file values can be overridden using command line arguments.
Config file values can be overridden using command-line arguments.
| Command line argument / Config file key | Description | Default value |
| Command-line argument / Config file key | Description | Default value |
| --------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------- |
| `--wait-interval`, `-w` / `wait_interval` | Wait interval between downloads in seconds. | `10` |
| `--wait-interval`, `-w` / `wait_interval` | Wait interval between downloads in seconds. | `5` |
| `--enable-videos` / `enable_videos` | Enable video downloads when available. | `false` |
| `--download-music-videos` / `download_music_videos` | List and select a related music video to download from songs. | `false` |
| `--download-podcast-videos` / `download_podcast_videos` | Attempt to download the video version of podcasts. | `false` |
| `--force-premium`, `-f` / `force_premium` | Force to detect the account as premium. | `false` |
| `--read-urls-as-txt`, `-r` / - | Interpret URLs as paths to text files containing URLs. | `false` |
| `--config-path` / - | Path to config file. | `<home>/.votify/config.json` |
| `--config-path` / - | Path to config file. | `<home>/.spotify-web-downloader/config.json` |
| `--log-level` / `log_level` | Log level. | `INFO` |
| `--print-exceptions` / `print_exceptions` | Print exceptions. | `false` |
| `--cookies-path` / `cookies_path` | Path to cookies file. | `./cookies.txt` |
| `--quality`, `-q` / `quality` | Audio quality. | `medium` |
| `--output-path`, `-o` / `output_path` | Path to output directory. | `./Spotify` |
| `--temp-path` / `temp_path` | Path to temporary directory. | `./temp` |
| `--download-mode`, `-d` / `download_mode` | Download mode. | `ytdlp` |
| `--no-exceptions` / `no_exceptions` | Don't print exceptions. | `false` |
| `--cookies-path` / `cookies_path` | Path to cookies file. | `cookies.txt` |
| `--output-path`, `-o` / `output_path` | Path to output directory. | `Spotify` |
| `--temp-path` / `temp_path` | Path to temporary directory. | `temp` |
| `--wvd-path` / `wvd_path` | Path to .wvd file. | `device.wvd` |
| `--aria2c-path` / `aria2c_path` | Path to aria2c binary. | `aria2c` |
| `--unplayplay-path` / `unplayplay_path` | Path to unplayplay binary. | `unplayplay` |
| `--ffmpeg-path` / `ffmpeg_path` | Path to ffmpeg binary. | `ffmpeg` |
| `--mp4box-path` / `mp4box_path` | Path to MP4Box binary. | `mp4box` |
| `--mp4decrypt-path` / `mp4decrypt_path` | Path to mp4decrypt binary. | `mp4decrypt` |
| `--packager-path` / `packager_path` | Path to Shaka Packager binary. | `packager` |
| `--template-folder-album` / `template_folder_album` | Template folder for tracks that are part of an album. | `{album_artist}/{album}` |
| `--template-folder-compilation` / `template_folder_compilation` | Template folder for tracks that are part of a compilation album. | `Compilations/{album}` |
| `--template-file-single-disc` / `template_file_single_disc` | Template file for the tracks that are part of a single-disc album. | `{track:02d} {title}` |
| `--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}` |
| `--template-folder-episode` / `template_folder_episode` | Template folder for episodes (podcasts). | `Podcasts/{album}` |
| `--template-file-episode` / `template_file_episode` | Template file for episodes (podcasts). | `{track:02d} {title}` |
| `--template-file-episode` / `template_file_episode` | Template file for music videos. | `{track:02d} {title}` |
| `--template-folder-music-video` / `template_folder_music_video` | Template folder for music videos | `{artist}/Unknown Album` |
| `--template-file-music-video` / `template_file_music_video` | Template file for the tracks that are not part of an album. | `{title}` |
| `--template-file-playlist` / `template_file_playlist` | Template file for the M3U8 playlist. | `Playlists/{playlist_artist}/{playlist_title}` |
| `--date-tag-template` / `date_tag_template` | Date tag template. | `%Y-%m-%dT%H:%M:%SZ` |
| `--save-cover`, `-s` / `save_cover` | Save cover as a separate file. | `false` |
| `--save-cover` / `save_cover` | Save cover as a separate file. | `false` |
| `--save-playlist` / `save_playlist` | Save a M3U8 playlist file when downloading a playlist. | `false` |
| `--overwrite` / `overwrite` | Overwrite existing files. | `false` |
| `--exclude-tags` / `exclude_tags` | Comma-separated tags to exclude. | `null` |
| `--truncate` / `truncate` | Maximum length of the file/folder names. | `null` |
| `--audio-quality`, `-a` / `audio_quality` | Audio quality for songs and podcasts. | `vorbis-medium` |
| `--download-mode`, `-d` / `download_mode` | Download mode for songs and podcasts. | `ytdlp` |
| `--remux-mode-audio` / `remux_mode_audio` | Remux mode for songs and podcasts. | `ffmpeg` |
| `--lrc-only`, `-l` / `lrc_only` | Download only the synced lyrics. | `false` |
| `--no-lrc` / `no_lrc` | Don't download the synced lyrics. | `false` |
| `--video-format` / `video_format` | Video format. | `mp4` |
| `--remux-mode-video` / `remux_mode_video` | Remux mode for videos. | `ffmpeg` |
| `--no-config-file`, `-n` / - | Do not use a config file. | `false` |
### Tag variables
The following variables can be used in the template folder/file and/or in the `exclude_tags` list:
- `album`
@@ -108,9 +153,11 @@ The following variables can be used in the template folder/file and/or in the `e
- `isrc`
- `label`
- `lyrics`
- `media_type`
- `playlist_artist`
- `playlist_title`
- `playlist_track`
- `publisher`
- `producer`
- `rating`
- `release_date`
@@ -119,22 +166,41 @@ The following variables can be used in the template folder/file and/or in the `e
- `track`
- `track_total`
- `url`
### Download qualities
### Audio qualities
The following qualities are available:
* `high` (320kbps, requires premium account)
* `medium` (160kbps)
* `low` (96kbps)
* `vorbis-high` (320kbps, requires an active premium subscription)
* `vorbis-medium` (160kbps)
* `vorbis-low` (96kbps)
* `aac-medium` (128kbps)
* `aac-high` (256kbps, requires an active premium subscription)
### Video formats
The following video formats are available:
* `mp4`
* `webm`
* `ask`
* When using this option, Votify will ask you which audio and video codec to use that is available for the video.
### Download modes
The following modes are available:
* `ytdlp`
* `aria2c`
* Will not be used for downloading videos
* Faster than `ytdlp`
* Can be obtained from here: https://github.com/aria2/aria2/releases
### Video remux modes
The following remux modes for videos are available:
* `ffmpeg`
* `mp4box`
## Credits
### Audio remux modes
The following remux modes for songs and podcasts are available when downloading in AAC quality:
* `ffmpeg`
* `mp4box`
* `mp4decrypt`
### Credits
* [spotify-oggmp4-dl](https://github.com/DevLARLEY/spotify-oggmp4-dl)
* [spsync](https://github.com/baltitenger/spsync)
* [unplayplay](https://git.gay/uhwot/unplayplay)
* [unplayplay](https://git.gay/uhwot/unplayplay)
+1
View File
@@ -10,6 +10,7 @@ dependencies = [
"protobuf",
"pybase62",
"pycryptodome",
"re-unplayplay",
"yt-dlp",
]
readme = "README.md"
+1
View File
@@ -4,4 +4,5 @@ pillow
protobuf
pybase62
pycryptodome
re-unplayplay
yt-dlp
+1 -1
View File
@@ -1 +1 @@
__version__ = "1.3.3"
__version__ = "1.4"
+268 -47
View File
@@ -10,18 +10,35 @@ from pathlib import Path
import click
from . import __version__
from .constants import EXCLUDED_CONFIG_FILE_PARAMS, X_NOT_FOUND_STRING
from .constants import (
AAC_AUDIO_QUALITIES,
EXCLUDED_CONFIG_FILE_PARAMS,
PREMIUM_AUDIO_QUALITIES,
X_NOT_FOUND_STRING,
)
from .downloader import Downloader
from .downloader_audio import DownloaderAudio
from .downloader_episode import DownloaderEpisode
from .downloader_episode_video import DownloaderEpisodeVideo
from .downloader_music_video import DownloaderMusicVideo
from .downloader_song import DownloaderSong
from .enums import DownloadMode, Quality
from .downloader_video import DownloaderVideo
from .enums import (
AudioQuality,
DownloadMode,
RemuxModeAudio,
RemuxModeVideo,
VideoFormat,
)
from .spotify_api import SpotifyApi
logger = logging.getLogger("votify")
spotify_api_sig = inspect.signature(SpotifyApi.__init__)
downloader_sig = inspect.signature(Downloader.__init__)
downloader_audio_sig = inspect.signature(DownloaderAudio.__init__)
downloader_song_sig = inspect.signature(DownloaderSong.__init__)
downloader_video_sig = inspect.signature(DownloaderVideo.__init__)
def get_param_string(param: click.Parameter) -> str:
@@ -77,9 +94,24 @@ def load_config_file(
"--wait-interval",
"-w",
type=float,
default=10,
default=5,
help="Wait interval between downloads in seconds.",
)
@click.option(
"--enable-videos",
is_flag=True,
help="Enable video downloads when available.",
)
@click.option(
"--download-music-videos",
is_flag=True,
help="List and select a related music video to download from songs.",
)
@click.option(
"--download-podcast-videos",
is_flag=True,
help="Attempt to download the video version of podcasts.",
)
@click.option(
"--force-premium",
"-f",
@@ -105,9 +137,9 @@ def load_config_file(
help="Log level.",
)
@click.option(
"--print-exceptions",
"--no-exceptions",
is_flag=True,
help="Print exceptions.",
help="Don't print exceptions.",
)
# SpotifyApi specific options
@click.option(
@@ -117,13 +149,6 @@ def load_config_file(
help="Path to cookies file.",
)
# Downloader specific options
@click.option(
"--quality",
"-q",
type=Quality,
default=downloader_sig.parameters["quality"].default,
help="Audio quality.",
)
@click.option(
"--output-path",
"-o",
@@ -138,11 +163,10 @@ def load_config_file(
help="Path to temporary directory.",
)
@click.option(
"--download-mode",
"-d",
type=DownloadMode,
default=downloader_sig.parameters["download_mode"].default,
help="Download mode.",
"--wvd-path",
type=Path,
default=downloader_sig.parameters["wvd_path"].default,
help="Path to .wvd file.",
)
@click.option(
"--aria2c-path",
@@ -151,10 +175,28 @@ def load_config_file(
help="Path to aria2c binary.",
)
@click.option(
"--unplayplay-path",
"--ffmpeg-path",
type=str,
default=downloader_sig.parameters["unplayplay_path"].default,
help="Path to unplayplay binary.",
default=downloader_sig.parameters["ffmpeg_path"].default,
help="Path to ffmpeg binary.",
)
@click.option(
"--mp4box-path",
type=str,
default=downloader_sig.parameters["mp4box_path"].default,
help="Path to MP4Box binary.",
)
@click.option(
"--mp4decrypt-path",
type=str,
default=downloader_sig.parameters["mp4decrypt_path"].default,
help="Path to mp4decrypt binary.",
)
@click.option(
"--packager-path",
type=str,
default=downloader_sig.parameters["packager_path"].default,
help="Path to Shaka Packager binary.",
)
@click.option(
"--template-folder-album",
@@ -190,7 +232,19 @@ def load_config_file(
"--template-file-episode",
type=str,
default=downloader_sig.parameters["template_file_episode"].default,
help="Template file for episodes (podcasts).",
help="Template file for music videos.",
)
@click.option(
"--template-folder-music-video",
type=str,
default=downloader_sig.parameters["template_folder_music_video"].default,
help="Template folder for music videos",
)
@click.option(
"--template-file-music-video",
type=str,
default=downloader_sig.parameters["template_file_music_video"].default,
help="Template file for the tracks that are not part of an album.",
)
@click.option(
"--template-file-playlist",
@@ -206,7 +260,6 @@ def load_config_file(
)
@click.option(
"--save-cover",
"-s",
is_flag=True,
help="Save cover as a separate file.",
)
@@ -232,6 +285,27 @@ def load_config_file(
default=downloader_sig.parameters["truncate"].default,
help="Maximum length of the file/folder names.",
)
# DownloaderAudio specific options
@click.option(
"--audio-quality",
"-a",
type=AudioQuality,
default=downloader_audio_sig.parameters["audio_quality"].default,
help="Audio quality for songs and podcasts.",
)
@click.option(
"--download-mode",
"-d",
type=DownloadMode,
default=downloader_audio_sig.parameters["download_mode"].default,
help="Download mode for songs and podcasts.",
)
@click.option(
"--remux-mode-audio",
type=RemuxModeAudio,
default=downloader_audio_sig.parameters["remux_mode"].default,
help="Remux mode for songs and podcasts.",
)
# DownloaderSong specific options
@click.option(
"--lrc-only",
@@ -244,6 +318,19 @@ def load_config_file(
is_flag=True,
help="Don't download the synced lyrics.",
)
# DownloaderVideo specific options
@click.option(
"--video-format",
type=VideoFormat,
default=downloader_video_sig.parameters["video_format"].default,
help="Video format.",
)
@click.option(
"--remux-mode-video",
type=RemuxModeVideo,
default=downloader_video_sig.parameters["remux_mode"].default,
help="Remux mode for videos.",
)
# This option should always be last
@click.option(
"--no-config-file",
@@ -255,24 +342,32 @@ def load_config_file(
def main(
urls: list[str],
wait_interval: float,
enable_videos: bool,
download_music_videos: bool,
download_podcast_videos: bool,
force_premium: bool,
read_urls_as_txt: bool,
config_path: Path,
log_level: str,
print_exceptions: bool,
no_exceptions: bool,
cookies_path: Path,
quality: Quality,
output_path: Path,
temp_path: Path,
wvd_path: Path,
download_mode: DownloadMode,
aria2c_path: str,
unplayplay_path: str,
ffmpeg_path: str,
mp4box_path: str,
mp4decrypt_path: str,
packager_path: str,
template_folder_album: str,
template_folder_compilation: str,
template_file_single_disc: str,
template_file_multi_disc: str,
template_folder_episode: str,
template_file_episode: str,
template_folder_music_video: str,
template_file_music_video: str,
template_file_playlist: str,
date_tag_template: str,
save_cover: bool,
@@ -280,8 +375,12 @@ def main(
overwrite: bool,
exclude_tags: str,
truncate: int,
audio_quality: AudioQuality,
remux_mode_audio: RemuxModeAudio,
lrc_only: bool,
no_lrc: bool,
video_format: VideoFormat,
remux_mode_video: RemuxModeVideo,
no_config_file: bool,
) -> None:
logging.basicConfig(
@@ -293,18 +392,22 @@ def main(
spotify_api = SpotifyApi(cookies_path)
downloader = Downloader(
spotify_api,
quality,
output_path,
temp_path,
download_mode,
wvd_path,
aria2c_path,
unplayplay_path,
ffmpeg_path,
mp4box_path,
mp4decrypt_path,
packager_path,
template_folder_album,
template_folder_compilation,
template_file_single_disc,
template_file_multi_disc,
template_folder_episode,
template_file_episode,
template_folder_music_video,
template_file_music_video,
template_file_playlist,
date_tag_template,
save_cover,
@@ -313,27 +416,115 @@ def main(
exclude_tags,
truncate,
)
downloader_song = DownloaderSong(
downloader_audio = DownloaderAudio(
downloader,
audio_quality,
download_mode,
remux_mode_audio,
)
downloader_song = DownloaderSong(
downloader_audio,
lrc_only,
no_lrc,
)
downloader_episode = DownloaderEpisode(
downloader_audio,
)
downloader_video = DownloaderVideo(
downloader,
video_format,
remux_mode_video,
)
downloader_episode_video = DownloaderEpisodeVideo(
downloader_video,
downloader_episode,
)
downloader_music_video = DownloaderMusicVideo(
downloader_video,
)
spotify_api.config_info["isPremium"] = (
True if force_premium else spotify_api.config_info["isPremium"]
)
if not lrc_only:
if not downloader.unplayplay_path_full:
logger.critical(X_NOT_FOUND_STRING.format("Unplayplay", unplayplay_path))
return
if audio_quality in AAC_AUDIO_QUALITIES:
if (
remux_mode_audio == RemuxModeAudio.FFMPEG
and not downloader.ffmpeg_path_full
):
logger.critical(X_NOT_FOUND_STRING.format("FFmpeg", ffmpeg_path))
if (
remux_mode_audio == RemuxModeAudio.MP4BOX
and not downloader.mp4box_path_full
):
logger.critical(X_NOT_FOUND_STRING.format("MP4Box", mp4box_path))
return
if (
remux_mode_audio
in (
RemuxModeAudio.MP4DECRYPT,
RemuxModeAudio.MP4BOX,
)
and not downloader.mp4decrypt_path_full
):
logger.critical(
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path)
)
return
if not wvd_path.exists() and audio_quality in AAC_AUDIO_QUALITIES:
logger.critical(
X_NOT_FOUND_STRING.format(".wvd", wvd_path)
+ ", a .wvd file is required for downloading in AAC quality"
)
return
downloader.set_cdm()
if download_mode == DownloadMode.ARIA2C and not downloader.aria2c_path_full:
logger.critical(X_NOT_FOUND_STRING.format("aria2c", aria2c_path))
return
spotify_api.config_info["isPremium"] = (
True if force_premium else spotify_api.config_info["isPremium"]
)
if not spotify_api.config_info["isPremium"] and quality == Quality.HIGH:
if (
not spotify_api.config_info["isPremium"]
and audio_quality in PREMIUM_AUDIO_QUALITIES
):
logger.critical("Cannot download at chosen quality with a free account")
return
can_download_music_videos = True
if enable_videos:
if (
downloader_music_video.remux_mode == RemuxModeVideo.FFMPEG
and not downloader.ffmpeg_path_full
):
logger.critical(X_NOT_FOUND_STRING.format("FFmpeg", ffmpeg_path))
return
if (
downloader_music_video.remux_mode == RemuxModeVideo.MP4BOX
and not downloader.mp4box_path_full
):
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:
music_video_warning_message.append(
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path)
)
elif not downloader.packager_path_full:
music_video_warning_message.append(
X_NOT_FOUND_STRING.format("Shaka Packager", packager_path)
)
if not wvd_path.exists():
music_video_warning_message.append(
X_NOT_FOUND_STRING.format(".wvd file", wvd_path)
)
else:
downloader.set_cdm()
if not spotify_api.config_info["isPremium"]:
music_video_warning_message.append(
"Cannot download music videos with a non-premium account"
)
if music_video_warning_message:
logger.warning(
"Music videos will not be downloaded due to the following reasons:\n"
+ "\n".join(music_video_warning_message)
)
can_download_music_videos = False
error_count = 0
if read_urls_as_txt:
_urls = []
@@ -357,7 +548,7 @@ def main(
error_count += 1
logger.error(
f'({url_progress}) Failed to check "{url}"',
exc_info=print_exceptions,
exc_info=no_exceptions,
)
continue
for index, download_queue_item in enumerate(download_queue, start=1):
@@ -371,27 +562,57 @@ def main(
)
media_id = downloader.get_media_id(media_metadata)
media_type = media_metadata["type"]
if media_type == "track":
gid_metadata = downloader.get_gid_metadata(media_id, media_type)
if gid_metadata.get("original_video") or (
media_type == "track" and download_music_videos
):
if not enable_videos or not can_download_music_videos:
logger.warning(
"Music videos are not downloadable with current "
"configuration, skipping"
)
continue
downloader_music_video.download(
music_video_id=media_id,
music_video_metadata=media_metadata,
album_metadata=download_queue_item.album_metadata,
gid_metadata=gid_metadata,
playlist_metadata=download_queue_item.playlist_metadata,
playlist_track=index,
)
elif media_type == "track":
downloader_song.download(
track_id=media_id,
track_metadata=media_metadata,
album_metadata=download_queue_item.album_metadata,
gid_metadata=gid_metadata,
playlist_metadata=download_queue_item.playlist_metadata,
playlist_track=index,
)
elif media_type == "episode" and not lrc_only:
downloader_episode.download(
episode_id=media_id,
episode_metadata=media_metadata,
show_metadata=download_queue_item.show_metadata,
playlist_metadata=download_queue_item.playlist_metadata,
playlist_track=index,
)
elif media_type == "episode":
if enable_videos and download_podcast_videos:
downloader_episode_video.download(
episode_id=media_id,
episode_metadata=media_metadata,
show_metadata=download_queue_item.show_metadata,
gid_metadata=gid_metadata,
playlist_metadata=download_queue_item.playlist_metadata,
playlist_track=index,
)
else:
downloader_episode.download(
episode_id=media_id,
episode_metadata=media_metadata,
show_metadata=download_queue_item.show_metadata,
gid_metadata=gid_metadata,
playlist_metadata=download_queue_item.playlist_metadata,
playlist_track=index,
)
except Exception as e:
error_count += 1
logger.error(
f'({queue_progress}) Failed to download "{media_metadata["name"]}"',
exc_info=print_exceptions,
exc_info=not no_exceptions,
)
finally:
if wait_interval > 0 and index != len(download_queue):
+37 -5
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from .enums import Quality
from .enums import AudioQuality
EXCLUDED_CONFIG_FILE_PARAMS = (
"urls",
@@ -32,10 +32,42 @@ VORBIS_TAGS_MAPPING = {
"url": "URL",
}
QUALITY_X_FORMAT_ID_MAPPING = {
Quality.HIGH: "OGG_VORBIS_320",
Quality.MEDIUM: "OGG_VORBIS_160",
Quality.LOW: "OGG_VORBIS_96",
MP4_TAGS_MAP = {
"album": "\xa9alb",
"album_artist": "aART",
"artist": "\xa9ART",
"composer": "\xa9wrt",
"copyright": "cprt",
"lyrics": "\xa9lyr",
"publisher": "\xa9pub",
"producer": "\xa9prd",
"rating": "rtng",
"release_date": "\xa9day",
"title": "\xa9nam",
"url": "\xa9url",
}
AUDIO_QUALITY_X_FORMAT_ID_MAPPING = {
AudioQuality.VORBIS_HIGH: "OGG_VORBIS_320",
AudioQuality.VORBIS_MEDIUM: "OGG_VORBIS_160",
AudioQuality.VORBIS_LOW: "OGG_VORBIS_96",
AudioQuality.AAC_HIGH: "MP4_256",
AudioQuality.AAC_MEDIUM: "MP4_128",
}
VORBIS_AUDIO_QUALITIES = (
AudioQuality.VORBIS_HIGH,
AudioQuality.VORBIS_MEDIUM,
AudioQuality.VORBIS_LOW,
)
AAC_AUDIO_QUALITIES = (AudioQuality.AAC_HIGH, AudioQuality.AAC_MEDIUM)
X_NOT_FOUND_STRING = "{} not found at {}"
PREMIUM_AUDIO_QUALITIES = (AudioQuality.VORBIS_HIGH, AudioQuality.AAC_HIGH)
MEDIA_TYPE_MP4_MAPPING = {
"Song": 1,
"Podcast": 21,
"Music video": 6,
}
+149 -130
View File
@@ -3,24 +3,25 @@ from __future__ import annotations
import base64
import datetime
import functools
import logging
import re
import shutil
import subprocess
from io import BytesIO
from pathlib import Path
from re_unplayplay import decrypt_and_bind_key
import requests
from Crypto.Cipher import AES
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from mutagen.flac import Picture
from mutagen.mp4 import MP4, MP4Cover, MP4FreeForm
from mutagen.oggvorbis import OggVorbis, OggVorbisHeaderError
from PIL import Image
from yt_dlp import YoutubeDL
from pywidevine import PSSH, Cdm, Device
from .constants import QUALITY_X_FORMAT_ID_MAPPING, VORBIS_TAGS_MAPPING
from .enums import DownloadMode, Quality
from .models import DownloadQueueItem, StreamInfo, UrlInfo
from .constants import MEDIA_TYPE_MP4_MAPPING, MP4_TAGS_MAP, VORBIS_TAGS_MAPPING
from .models import DownloadQueueItem, UrlInfo
from .playplay_pb2 import (
AUDIO_TRACK,
Interactivity,
@@ -30,6 +31,8 @@ from .playplay_pb2 import (
from .spotify_api import SpotifyApi
from .utils import check_response
logger = logging.getLogger("votify")
class Downloader:
ILLEGAL_CHARACTERS_REGEX = r'[\\/:*?"<>|;]'
@@ -44,18 +47,22 @@ class Downloader:
def __init__(
self,
spotify_api: SpotifyApi,
quality: Quality = Quality.MEDIUM,
output_path: Path = Path("./Spotify"),
temp_path: Path = Path("./temp"),
download_mode: DownloadMode = DownloadMode.YTDLP,
aria2c_path: Path = "aria2c",
unplayplay_path: Path = "unplayplay",
wvd_path: Path = Path("./device.wvd"),
aria2c_path: str = "aria2c",
ffmpeg_path: str = "ffmpeg",
mp4box_path: str = "mp4box",
mp4decrypt_path: str = "mp4decrypt",
packager_path: str = "packager",
template_folder_album: str = "{album_artist}/{album}",
template_folder_compilation: str = "Compilations/{album}",
template_file_single_disc: str = "{track:02d} {title}",
template_file_multi_disc: str = "{disc}-{track:02d} {title}",
template_folder_episode: str = "Podcasts/{album}",
template_file_episode: str = "{track:02d} {title}",
template_folder_music_video: str = "{artist}/Unknown Album",
template_file_music_video: str = "{title}",
template_file_playlist: str = "Playlists/{playlist_artist}/{playlist_title}",
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
save_cover: bool = False,
@@ -64,20 +71,25 @@ class Downloader:
exclude_tags: str = None,
truncate: int = None,
silence: bool = False,
skip_cleanup: bool = False,
):
self.spotify_api = spotify_api
self.quality = quality
self.output_path = output_path
self.temp_path = temp_path
self.download_mode = download_mode
self.wvd_path = wvd_path
self.aria2c_path = aria2c_path
self.unplayplay_path = unplayplay_path
self.ffmpeg_path = ffmpeg_path
self.mp4box_path = mp4box_path
self.mp4decrypt_path = mp4decrypt_path
self.packager_path = packager_path
self.template_folder_album = template_folder_album
self.template_folder_compilation = template_folder_compilation
self.template_file_single_disc = template_file_single_disc
self.template_file_multi_disc = template_file_multi_disc
self.template_folder_episode = template_folder_episode
self.template_file_episode = template_file_episode
self.template_folder_music_video = template_folder_music_video
self.template_file_music_video = template_file_music_video
self.template_file_playlist = template_file_playlist
self.date_tag_template = date_tag_template
self.save_cover = save_cover
@@ -86,6 +98,7 @@ class Downloader:
self.exclude_tags = exclude_tags
self.truncate = truncate
self.silence = silence
self.skip_cleanup = skip_cleanup
self._set_binaries_full_path()
self._set_exclude_tags_list()
self._set_truncate()
@@ -93,7 +106,10 @@ class Downloader:
def _set_binaries_full_path(self):
self.aria2c_path_full = shutil.which(self.aria2c_path)
self.unplayplay_path_full = shutil.which(self.unplayplay_path)
self.ffmpeg_path_full = shutil.which(self.ffmpeg_path)
self.mp4box_path_full = shutil.which(self.mp4box_path)
self.mp4decrypt_path_full = shutil.which(self.mp4decrypt_path)
self.packager_path_full = shutil.which(self.packager_path)
def _set_exclude_tags_list(self):
self.exclude_tags_list = (
@@ -115,6 +131,9 @@ class Downloader:
else:
self.subprocess_additional_args = {}
def set_cdm(self) -> None:
self.cdm = Cdm.from_device(Device.load(self.wvd_path))
def get_url_info(self, url: str) -> UrlInfo:
url_regex_result = re.search(self.URL_RE, url)
if url_regex_result is None:
@@ -289,6 +308,9 @@ class Downloader:
elif media_type == "episode":
template_folder = self.template_folder_episode.split("/")
template_file = self.template_file_episode.split("/")
elif media_type == "music-video":
template_folder = self.template_folder_music_video.split("/")
template_file = self.template_file_music_video.split("/")
else:
raise RuntimeError()
template_final = template_folder + template_file
@@ -330,18 +352,6 @@ class Downloader:
with playlist_file_path.open("w", encoding="utf8") as playlist_file:
playlist_file.writelines(playlist_file_lines)
def get_audio_file(
self,
audio_files: list[dict],
) -> tuple[Quality, dict] | tuple[None, None]:
qualities = list(Quality)
start_index = qualities.index(self.quality)
for quality in qualities[start_index:]:
for audio_file in audio_files:
if audio_file["format"] == QUALITY_X_FORMAT_ID_MAPPING[quality]:
return quality, audio_file
return None, None
def get_gid_metadata(
self,
media_id: str,
@@ -350,34 +360,7 @@ class Downloader:
gid = self.spotify_api.media_id_to_gid(media_id)
return self.spotify_api.get_gid_metadata(gid, media_type)
def get_stream_info(
self,
gid_metadata: dict,
media_type: str,
) -> StreamInfo:
stream_info = StreamInfo()
if media_type == "track":
audio_files = gid_metadata.get("file")
elif media_type == "episode":
audio_files = gid_metadata.get("audio")
else:
raise RuntimeError()
audio_files = audio_files or gid_metadata.get("alternative")
if not audio_files:
return stream_info
if audio_files[0].get("gid"):
audio_files = audio_files[0]["file"]
quality, audio_file = self.get_audio_file(audio_files)
if not audio_file:
return stream_info
file_id = audio_file["file_id"]
stream_url = self.spotify_api.get_stream_urls(file_id)["cdnurl"][0]
stream_info.stream_url = stream_url
stream_info.file_id = file_id
stream_info.quality = quality
return stream_info
def get_decryption_key(self, file_id: str) -> bytes:
def get_playplay_decryption_key(self, file_id: str) -> bytes:
playplay_license_request = PlayPlayLicenseRequest(
version=2,
token=bytes.fromhex("01e132cae527bd21620e822f58514932"),
@@ -390,74 +373,29 @@ class Downloader:
)
playplay_license_response = PlayPlayLicenseResponse()
playplay_license_response.ParseFromString(playplay_license_response_bytes)
obfuscated = playplay_license_response.obfuscated_key.hex()
output = subprocess.check_output(
[
self.unplayplay_path_full,
file_id,
obfuscated,
],
shell=False,
)
key = bytes.fromhex(output.strip().decode("utf-8"))
assert key
obfuscated_key = playplay_license_response.obfuscated_key
key = decrypt_and_bind_key(obfuscated_key, file_id)
return key
def download_stream_url(self, input_path: Path, stream_url: str):
if self.download_mode == DownloadMode.YTDLP:
self.download_stream_url_ytdlp(input_path, stream_url)
elif self.download_mode == DownloadMode.ARIA2C:
self.download_stream_url_aria2c(input_path, stream_url)
def download_stream_url_ytdlp(self, input_path: Path, stream_url: str) -> None:
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"outtmpl": str(input_path),
"allow_unplayable_formats": True,
"fixup": "never",
"allowed_extractors": ["generic"],
"noprogress": self.silence,
}
) as ydl:
ydl.download(stream_url)
def download_stream_url_aria2c(self, input_path: Path, stream_url: str) -> None:
input_path.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
self.aria2c_path_full,
"--no-conf",
"--download-result=hide",
"--console-log-level=error",
"--summary-interval=0",
"--file-allocation=none",
stream_url,
"--out",
input_path,
],
check=True,
**self.subprocess_additional_args,
)
print("\r", end="")
def decrypt(
def get_widevine_decryption_key(
self,
decryption_key: bytes,
encrypted_path: Path,
decrypted_path: Path,
):
cipher = AES.new(
decryption_key,
AES.MODE_CTR,
nonce=bytes.fromhex("72e067fbddcbcf77"),
initial_value=bytes.fromhex("ebe8bc643f630d93"),
)
skip = 167
with decrypted_path.open("wb") as decrypted_file:
with encrypted_path.open("rb") as encrypted_file:
decrypted_file.write(cipher.decrypt(encrypted_file.read())[skip:])
pssh: bytes,
media_type: str,
) -> tuple[str, str]:
try:
pssh = PSSH(pssh)
cdm_session = self.cdm.open()
challenge = self.cdm.get_license_challenge(cdm_session, pssh)
license = self.spotify_api.get_widevine_license(challenge, media_type)
self.cdm.parse_license(cdm_session, license)
keys = next(
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
)
decryption_key = keys.key.hex()
key_id = keys.kid.hex
finally:
self.cdm.close(cdm_session)
return key_id, decryption_key
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
dirty_string = re.sub(
@@ -503,19 +441,15 @@ class Downloader:
def _get_cover_url(self, images_dict: list[dict]) -> str:
return max(images_dict, key=lambda img: img["height"])["url"]
def get_encrypted_path(
def get_file_temp_path(
self,
track_id: str,
extra_string: str,
extension: str,
) -> Path:
return self.temp_path / (f"{track_id}_encrypted.ogg")
return self.temp_path / (track_id + extra_string + extension)
def get_decrypted_path(
self,
track_id: str,
) -> Path:
return self.temp_path / (f"{track_id}_decrypted.ogg")
def apply_tags(
def apply_tags_ogg(
self,
input_path: Path,
tags: dict,
@@ -544,6 +478,94 @@ class Downloader:
except OggVorbisHeaderError:
pass
def apply_tags_mp4(self, fixed_location: Path, tags: dict, cover_url: str):
to_apply_tags = [
tag_name
for tag_name in tags.keys()
if tag_name not in self.exclude_tags_list
]
mp4_tags = {}
for tag_name in to_apply_tags:
if tags.get(tag_name) is None:
continue
if tag_name in ("disc", "disc_total"):
if mp4_tags.get("disk") is None:
mp4_tags["disk"] = [[0, 0]]
if tag_name == "disc":
mp4_tags["disk"][0][0] = tags[tag_name]
elif tag_name == "disc_total":
mp4_tags["disk"][0][1] = tags[tag_name]
elif tag_name in ("track", "track_total"):
if mp4_tags.get("trkn") is None:
mp4_tags["trkn"] = [[0, 0]]
if tag_name == "track":
mp4_tags["trkn"][0][0] = tags[tag_name]
elif tag_name == "track_total":
mp4_tags["trkn"][0][1] = tags[tag_name]
elif tag_name == "compilation":
mp4_tags["cpil"] = tags["compilation"]
elif tag_name == "isrc":
mp4_tags["----:com.apple.iTunes:ISRC"] = [
MP4FreeForm(tags["isrc"].encode("utf-8"))
]
elif tag_name == "label":
mp4_tags["----:com.apple.iTunes:LABEL"] = [
MP4FreeForm(tags["label"].encode("utf-8"))
]
elif tag_name == "rating":
mp4_tags["rtng"] = [1] if tags[tag_name] == "Explicit" else [0]
elif tag_name == "media_type":
mp4_tags["stik"] = [MEDIA_TYPE_MP4_MAPPING[tags[tag_name]]]
elif MP4_TAGS_MAP.get(tag_name) is not None:
mp4_tags[MP4_TAGS_MAP[tag_name]] = [tags[tag_name]]
if "cover" not in self.exclude_tags_list and cover_url is not None:
mp4_tags["covr"] = [
MP4Cover(
self.get_response_bytes(cover_url), imageformat=MP4Cover.FORMAT_JPEG
)
]
mp4 = MP4(fixed_location)
mp4.clear()
mp4.update(mp4_tags)
mp4.save()
def _final_processing(
self,
cover_path: Path,
cover_url: str,
media_temp_path: Path,
final_path: Path,
tags: dict,
playlist_metadata: dict,
playlist_track: int,
):
if self.save_cover and cover_path.exists() and not self.overwrite:
logger.debug(f'Cover already exists at "{cover_path}", skipping')
elif self.save_cover and cover_url is not None:
logger.debug(f'Saving cover to "{cover_path}"')
self.save_cover_file(cover_path, cover_url)
if media_temp_path:
logger.debug("Applying tags")
if media_temp_path.suffix in (".mp4", ".m4a"):
self.apply_tags_mp4(media_temp_path, tags, cover_url)
elif media_temp_path.suffix == ".ogg":
self.apply_tags_ogg(media_temp_path, tags, cover_url)
logger.debug(f'Moving to "{final_path}"')
self.move_to_final_path(media_temp_path, final_path)
if self.save_playlist and playlist_metadata:
playlist_file_path = self.get_playlist_file_path(tags)
logger.debug(f'Updating M3U8 playlist from "{playlist_file_path}"')
self.update_playlist_file(
playlist_file_path,
final_path,
playlist_track,
)
def cleanup_temp_path(self):
if self.temp_path.exists() and not self.skip_cleanup:
logger.debug(f'Cleaning up "{self.temp_path}"')
shutil.rmtree(self.temp_path)
@staticmethod
@functools.lru_cache()
def get_response_bytes(url: str) -> bytes:
@@ -560,6 +582,3 @@ class Downloader:
if cover_url is not None:
cover_path.parent.mkdir(parents=True, exist_ok=True)
cover_path.write_bytes(self.get_response_bytes(cover_url))
def cleanup_temp_path(self):
shutil.rmtree(self.temp_path)
+248
View File
@@ -0,0 +1,248 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from Crypto.Cipher import AES
from yt_dlp.downloader.http import HttpFD
from yt_dlp.YoutubeDL import YoutubeDL
from .constants import (
AAC_AUDIO_QUALITIES,
AUDIO_QUALITY_X_FORMAT_ID_MAPPING,
VORBIS_AUDIO_QUALITIES,
)
from .downloader import Downloader
from .enums import AudioQuality, DownloadMode, RemuxModeAudio
from .models import StreamInfoAudio
class DownloaderAudio:
def __init__(
self,
downloader: Downloader,
audio_quality: AudioQuality = AudioQuality.VORBIS_MEDIUM,
download_mode: DownloadMode = DownloadMode.YTDLP,
remux_mode: RemuxModeAudio = RemuxModeAudio.FFMPEG,
):
self.downloader = downloader
self.audio_quality = audio_quality
self.download_mode = download_mode
self.remux_mode = remux_mode
def get_file_extension(
self,
) -> str:
if self.audio_quality in AAC_AUDIO_QUALITIES:
return ".m4a"
else:
return ".ogg"
def get_audio_file(
self,
audio_files: list[dict],
) -> tuple[AudioQuality, dict] | tuple[None, None]:
if self.audio_quality in AAC_AUDIO_QUALITIES:
qualities = AAC_AUDIO_QUALITIES
else:
qualities = VORBIS_AUDIO_QUALITIES
start_index = qualities.index(self.audio_quality)
for quality in qualities[start_index:]:
for audio_file in audio_files:
if audio_file["format"] == AUDIO_QUALITY_X_FORMAT_ID_MAPPING[quality]:
return quality, audio_file
return None, None
def get_stream_info(
self,
gid_metadata: dict,
media_type: str,
) -> StreamInfoAudio:
stream_info = StreamInfoAudio()
if media_type == "track":
audio_files = gid_metadata.get("file")
elif media_type == "episode":
audio_files = gid_metadata.get("audio")
else:
raise RuntimeError()
audio_files = audio_files or gid_metadata.get("alternative")
if not audio_files:
return stream_info
if audio_files[0].get("gid"):
audio_files = audio_files[0]["file"]
quality, audio_file = self.get_audio_file(audio_files)
if not audio_file:
return stream_info
file_id = audio_file["file_id"]
stream_url = self.downloader.spotify_api.get_stream_urls(file_id)["cdnurl"][0]
stream_info.stream_url = stream_url
stream_info.file_id = file_id
stream_info.quality = quality
if self.audio_quality in AAC_AUDIO_QUALITIES:
seek_table = self.downloader.spotify_api.get_seek_table(file_id)
pssh = seek_table["pssh"]
stream_info.widevine_pssh = pssh
return stream_info
def get_decryption_key(self, stream_info: StreamInfoAudio) -> str:
if self.audio_quality in AAC_AUDIO_QUALITIES:
_, decryption_key = self.downloader.get_widevine_decryption_key(
stream_info.widevine_pssh,
"audio",
)
else:
decryption_key = self.downloader.get_playplay_decryption_key(
stream_info.file_id
)
return decryption_key
def download_stream_url(self, input_path: Path, stream_url: str):
if self.download_mode == DownloadMode.YTDLP:
self.download_stream_url_ytdlp(input_path, stream_url)
elif self.download_mode == DownloadMode.ARIA2C:
self.download_stream_url_aria2c(input_path, stream_url)
def download_stream_url_ytdlp(self, input_path: Path, stream_url: str) -> None:
input_path.parent.mkdir(parents=True, exist_ok=True)
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"noprogress": self.downloader.silence,
}
) as ydl:
http_downloader = HttpFD(ydl, ydl.params)
http_downloader.download(
str(input_path),
{
"url": stream_url,
},
)
def download_stream_url_aria2c(self, input_path: Path, stream_url: str) -> None:
input_path.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
self.downloader.aria2c_path_full,
"--no-conf",
"--download-result=hide",
"--console-log-level=error",
"--summary-interval=0",
"--file-allocation=none",
stream_url,
"--out",
input_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
print("\r", end="")
def decrypt(
self,
decryption_key: bytes | str,
encrypted_path: Path,
decrypted_path: Path,
remuxed_path: Path,
):
if self.audio_quality in AAC_AUDIO_QUALITIES:
if self.remux_mode == RemuxModeAudio.FFMPEG:
self.decrypt_widevine_ffmpeg(
decryption_key,
encrypted_path,
decrypted_path,
)
else:
self.decrypt_widevine_mp4decrypt(
decryption_key,
encrypted_path,
decrypted_path,
)
if self.remux_mode == RemuxModeAudio.MP4BOX:
self.remux_mp4box(decrypted_path, remuxed_path)
else:
self.decrypt_playplay(
decryption_key,
encrypted_path,
decrypted_path,
)
def decrypt_playplay(
self,
decryption_key: bytes,
encrypted_path: Path,
decrypted_path: Path,
):
cipher = AES.new(
decryption_key,
AES.MODE_CTR,
nonce=bytes.fromhex("72e067fbddcbcf77"),
initial_value=bytes.fromhex("ebe8bc643f630d93"),
)
skip = 167
with decrypted_path.open("wb") as decrypted_file:
with encrypted_path.open("rb") as encrypted_file:
decrypted_file.write(cipher.decrypt(encrypted_file.read())[skip:])
def decrypt_widevine_ffmpeg(
self,
decryption_key: str,
encrypted_path: Path,
decrypted_path: Path,
):
subprocess.run(
[
self.downloader.ffmpeg_path_full,
"-loglevel",
"error",
"-y",
"-decryption_key",
decryption_key,
"-i",
encrypted_path,
"-c",
"copy",
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def decrypt_widevine_mp4decrypt(
self,
decryption_key: str,
encrypted_path: Path,
decrypted_path: Path,
):
subprocess.run(
[
self.downloader.mp4decrypt_path_full,
"--key",
f"1:{decryption_key}",
encrypted_path,
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_mp4box(
self,
decrypted_path: Path,
remuxed_path: Path,
):
subprocess.run(
[
self.downloader.mp4box_path_full,
"-quiet",
"-itags",
"artist=placeholder",
"-keep-utc",
"-add",
decrypted_path,
"-new",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
+49 -40
View File
@@ -3,18 +3,18 @@ from __future__ import annotations
import logging
from pathlib import Path
from .downloader import Downloader
from .models import StreamInfo
from .downloader_audio import DownloaderAudio
from .models import StreamInfoAudio
logger = logging.getLogger("votify")
class DownloaderEpisode:
class DownloaderEpisode(DownloaderAudio):
def __init__(
self,
downloader: Downloader,
downloader_audio: DownloaderAudio,
):
self.downloader = downloader
self.__dict__.update(downloader_audio.__dict__)
def get_tags(
self,
@@ -28,6 +28,7 @@ class DownloaderEpisode:
tags = {
"album": show_metadata["name"],
"description": episode_metadata["description"],
"media_type": "Podcast",
"publisher": show_metadata.get("publisher"),
"rating": "Explicit" if episode_metadata.get("explicit") else "Unknown",
"release_date": self.downloader.get_release_date_tag(
@@ -58,9 +59,7 @@ class DownloaderEpisode:
try:
self._download(*args, **kwargs)
finally:
if self.downloader.temp_path.exists():
logger.debug(f'Cleaning up "{self.downloader.temp_path}"')
self.downloader.cleanup_temp_path()
self.downloader.cleanup_temp_path()
def _download(
self,
@@ -68,7 +67,7 @@ class DownloaderEpisode:
episode_metadata: dict = None,
show_metadata: dict = None,
gid_metadata: dict = None,
stream_info: StreamInfo = None,
stream_info: StreamInfoAudio = None,
playlist_metadata: dict = None,
playlist_track: int = None,
decryption_key: bytes = None,
@@ -86,14 +85,14 @@ class DownloaderEpisode:
gid_metadata = self.downloader.get_gid_metadata(episode_id, "episode")
if not stream_info:
logger.debug("Getting stream info")
stream_info = self.downloader.get_stream_info(gid_metadata, "episode")
stream_info = self.get_stream_info(gid_metadata, "episode")
if not stream_info.file_id:
logger.warning(
"Episode is not available on Spotify's "
"servers and no alternative found, skipping"
)
return
if stream_info.quality != self.downloader.quality:
if stream_info.quality != self.audio_quality:
logger.warning(f"Quality has been changed to {stream_info.quality.value}")
tags = self.get_tags(
episode_metadata,
@@ -107,49 +106,59 @@ class DownloaderEpisode:
playlist_track,
),
}
file_extension = self.get_file_extension()
final_path = self.downloader.get_final_path(
"episode",
tags,
".ogg",
file_extension,
)
cover_path = self.get_cover_path(final_path)
cover_url = self.downloader.get_cover_url(episode_metadata)
decrypted_path = None
remuxed_path = None
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.downloader.get_decryption_key(stream_info.file_id)
encrypted_path = self.downloader.get_encrypted_path(episode_id)
decrypted_path = self.downloader.get_decrypted_path(episode_id)
decryption_key = self.get_decryption_key(stream_info)
encrypted_path = self.downloader.get_file_temp_path(
episode_id,
"_encrypted",
file_extension,
)
decrypted_path = self.downloader.get_file_temp_path(
episode_id,
"_decrypted",
file_extension,
)
remuxed_path = self.downloader.get_file_temp_path(
episode_id,
"_remuxed",
file_extension,
)
logger.debug(f'Downloading to "{encrypted_path}"')
self.downloader.download_stream_url(encrypted_path, stream_info.stream_url)
logger.debug(f'Decrypting to "{decrypted_path}"')
self.downloader.decrypt(
self.download_stream_url(encrypted_path, stream_info.stream_url)
logger.debug(
f'Decrypting to "{decrypted_path}" and remuxing to "{remuxed_path}"'
)
self.decrypt(
decryption_key,
encrypted_path,
decrypted_path,
remuxed_path,
)
if (
self.downloader.save_cover
and cover_path.exists()
and not self.downloader.overwrite
):
logger.debug(f'Cover already exists at "{cover_path}", skipping')
elif self.downloader.save_cover and cover_url is not None:
logger.debug(f'Saving cover to "{cover_path}"')
self.downloader.save_cover_file(cover_path, cover_url)
if decrypted_path:
logger.debug("Applying tags")
self.downloader.apply_tags(decrypted_path, tags, cover_url)
logger.debug(f'Moving to "{final_path}"')
self.downloader.move_to_final_path(decrypted_path, final_path)
if self.downloader.save_playlist and playlist_metadata:
playlist_file_path = self.downloader.get_playlist_file_path(tags)
logger.debug(f'Updating M3U8 playlist from "{playlist_file_path}"')
self.downloader.update_playlist_file(
playlist_file_path,
final_path,
playlist_track,
)
media_temp_path = (
remuxed_path
if remuxed_path is not None and remuxed_path.exists()
else decrypted_path
)
self.downloader._final_processing(
cover_path,
cover_url,
media_temp_path,
final_path,
tags,
playlist_metadata,
playlist_track,
)
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import logging
from .downloader_episode import DownloaderEpisode
from .downloader_video import DownloaderVideo
logger = logging.getLogger("votify")
class DownloaderEpisodeVideo(DownloaderVideo):
def __init__(
self,
downloader_video: DownloaderVideo,
downloader_episode: DownloaderEpisode,
):
self.__dict__.update(downloader_video.__dict__)
self.downloader_episode = downloader_episode
def get_video_gid(self, gid_metadata: dict) -> str | None:
if not gid_metadata.get("video"):
return None
return gid_metadata["video"][0]["file_id"]
def download(
self,
*args,
**kwargs,
):
try:
self._download(*args, **kwargs)
finally:
self.downloader.cleanup_temp_path()
def _download(
self,
episode_id: str,
episode_metadata: dict = None,
show_metadata: dict = None,
gid_metadata: dict = None,
playlist_metadata: dict = None,
playlist_track: int = None,
):
if not episode_metadata:
logger.debug("Getting episode metadata")
episode_metadata = self.downloader.spotify_api.get_episode(episode_id)
if not show_metadata:
logger.debug("Getting show metadata")
show_metadata = self.downloader.spotify_api.get_show(
episode_metadata["show"]["id"]
)
if not gid_metadata:
logger.debug("Getting GID metadata")
gid_metadata = self.downloader.get_gid_metadata(episode_id, "episode")
video_gid = self.get_video_gid(gid_metadata)
if not video_gid:
logger.warning("Episode has no video, skipping")
return
stream_info = self.get_stream_info(video_gid)
tags = self.downloader_episode.get_tags(
episode_metadata,
show_metadata,
)
file_extension = self.get_file_extension(
stream_info.file_type_video,
stream_info.file_type_audio,
)
final_path = self.downloader.get_final_path(
"episode",
tags,
file_extension,
)
cover_path = self.get_cover_path(final_path)
cover_url = self.downloader_episode.downloader.get_cover_url(show_metadata)
remuxed_path = None
if final_path.exists() and not self.downloader.overwrite:
logger.warning(f'Episode already exists at "{final_path}", skipping')
return
else:
temp_path_video = self.downloader.get_file_temp_path(
episode_id,
"_video",
file_extension,
)
temp_path_audio = self.downloader.get_file_temp_path(
episode_id,
"_audio",
file_extension,
)
remuxed_path = self.downloader.get_file_temp_path(
episode_id,
"_remuxed",
file_extension,
)
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,
remuxed_path,
)
self.downloader._final_processing(
cover_path,
cover_url,
remuxed_path,
final_path,
tags,
playlist_metadata,
playlist_track,
)
+251
View File
@@ -0,0 +1,251 @@
from __future__ import annotations
import logging
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from .downloader_video import DownloaderVideo
logger = logging.getLogger("votify")
class DownloaderMusicVideo(DownloaderVideo):
def __init__(
self,
downloader_video: DownloaderVideo,
):
self.__dict__.update(downloader_video.__dict__)
def get_video_gid(self, gid_metadata: dict) -> str | None:
if not gid_metadata.get("original_video"):
return None
return gid_metadata["original_video"][0]["gid"]
def get_tags(
self,
track_metadata: dict,
album_metadata: dict,
track_credits: dict,
) -> dict:
external_ids = track_metadata.get("external_ids")
external_urls = (track_metadata.get("linked_from") or track_metadata)[
"external_urls"
]
release_date_datetime_obj = self.downloader.get_release_date_datetime_obj(
album_metadata["release_date"],
album_metadata["release_date_precision"],
)
producers = next(
role
for role in track_credits["roleCredits"]
if role["roleTitle"] == "Producers"
)["artists"]
composers = next(
role
for role in track_credits["roleCredits"]
if role["roleTitle"] == "Writers"
)["artists"]
tags = {
"artist": self.downloader.get_artist_string(track_metadata["artists"]),
"composer": (
self.downloader.get_artist_string(composers) if composers else None
),
"copyright": next(
(i["text"] for i in album_metadata["copyrights"] if i["type"] == "P"),
None,
),
"isrc": external_ids.get("isrc") if external_ids is not None else None,
"label": album_metadata.get("label"),
"media_type": "Music video",
"producer": (
self.downloader.get_artist_string(producers) if producers else None
),
"rating": "Explicit" if track_metadata.get("explicit") else "Unknown",
"title": track_metadata["name"],
"release_date": self.downloader.get_release_date_tag(
release_date_datetime_obj
),
"release_year": str(release_date_datetime_obj.year),
"url": external_urls["spotify"],
}
tags["release_year"] = str(release_date_datetime_obj.year)
return tags
def get_music_video_id_from_song_id(
self,
track_id: str,
artist_id: str,
) -> dict | None:
now_playing_view = self.downloader.spotify_api.get_now_playing_view(
track_id, artist_id
)
related_music_videos = now_playing_view["data"]["trackUnion"]["relatedVideos"][
"items"
]
if not related_music_videos:
return
choices = [
Choice(
name="None",
value=None,
)
]
choices.extend(
[
Choice(
name=" | ".join(
[
self.downloader.get_artist_string(
[
{"name": artist["profile"]["name"]}
for artist in related_music_video["trackOfVideo"][
"data"
]["artists"]["items"]
]
),
related_music_video["trackOfVideo"]["data"]["name"],
]
),
value=related_music_video["trackOfVideo"]["data"]["uri"].split(":")[
-1
],
)
for related_music_video in related_music_videos
]
)
selected_music_video_id = inquirer.select(
message="Select which music video to download: (Artist | Title)",
choices=choices,
).execute()
return selected_music_video_id
def download(
self,
*args,
**kwargs,
):
try:
self._download(*args, **kwargs)
finally:
self.downloader.cleanup_temp_path()
def _download(
self,
music_video_id: str,
music_video_metadata: dict = None,
album_metadata: dict = None,
gid_metadata: dict = None,
playlist_metadata: dict = None,
playlist_track: int = None,
):
if not music_video_metadata:
logger.debug("Getting music video metadata")
music_video_metadata = self.downloader.spotify_api.get_track(music_video_id)
if not album_metadata:
logger.debug("Getting album metadata")
album_metadata = self.downloader.spotify_api.get_album(
music_video_metadata["album"]["id"]
)
if not gid_metadata:
logger.debug("Getting GID metadata")
gid_metadata = self.downloader.get_gid_metadata(music_video_id, "track")
video_gid = self.get_video_gid(gid_metadata)
if not video_gid:
logger.debug("Getting equivalent music video ID from song ID")
music_video_id = self.get_music_video_id_from_song_id(
music_video_id,
album_metadata["artists"][0]["id"],
)
if not music_video_id:
logger.warning(
"No related music videos found or no music video selected, skipping"
)
return
music_video_metadata = self.downloader.spotify_api.get_track(music_video_id)
logger.warning(
f'Switching to downloading music video "{music_video_metadata["name"]}"'
)
album_metadata = self.downloader.spotify_api.get_album(
music_video_metadata["album"]["id"]
)
gid_metadata = self.downloader.get_gid_metadata(music_video_id, "track")
video_gid = self.get_video_gid(gid_metadata)
stream_info = self.get_stream_info(video_gid)
logger.debug("Getting credits")
track_credits = self.downloader.spotify_api.get_track_credits(music_video_id)
tags = self.get_tags(
music_video_metadata,
album_metadata,
track_credits,
)
file_extension = self.get_file_extension(
stream_info.file_type_video,
stream_info.file_type_audio,
)
final_path = self.downloader.get_final_path(
"music-video",
tags,
file_extension,
)
cover_path = self.get_cover_path(final_path)
cover_url = self.downloader.get_cover_url(album_metadata)
remuxed_path = None
if final_path.exists() and not self.downloader.overwrite:
logger.warning(f'Music video already exists at "{final_path}", skipping')
return
else:
key_id, decryption_key = self.downloader.get_widevine_decryption_key(
stream_info.encryption_data_widevine,
"video",
)
encrypted_path_video = self.downloader.get_file_temp_path(
music_video_id,
"_video_encrypted",
file_extension,
)
encrypted_path_audio = self.downloader.get_file_temp_path(
music_video_id,
"_audio_encrypted",
file_extension,
)
decrypted_path_video = self.downloader.get_file_temp_path(
music_video_id,
"_video_decrypted",
file_extension,
)
decrypted_path_audio = self.downloader.get_file_temp_path(
music_video_id,
"_audio_decrypted",
file_extension,
)
remuxed_path = self.downloader.get_file_temp_path(
music_video_id,
"_remuxed",
file_extension,
)
logger.debug(f'Downloading video to "{encrypted_path_video}"')
self.download_segments(stream_info.segment_urls_video, encrypted_path_video)
logger.debug(f'Downloading audio to "{encrypted_path_audio}"')
self.download_segments(stream_info.segment_urls_audio, encrypted_path_audio)
logger.debug(
f'Decryping video/audio to "{decrypted_path_video}/{decrypted_path_audio}" and remuxing to "{remuxed_path}"'
)
self.remux(
decrypted_path_video,
decrypted_path_audio,
remuxed_path,
key_id,
decryption_key,
encrypted_path_video,
encrypted_path_audio,
)
self.downloader._final_processing(
cover_path,
cover_url,
remuxed_path,
final_path,
tags,
playlist_metadata,
playlist_track,
)
+49 -41
View File
@@ -4,20 +4,20 @@ import datetime
import logging
from pathlib import Path
from .downloader import Downloader
from .models import Lyrics, StreamInfo
from .downloader_audio import DownloaderAudio
from .models import Lyrics, StreamInfoAudio
logger = logging.getLogger("votify")
class DownloaderSong:
class DownloaderSong(DownloaderAudio):
def __init__(
self,
downloader: Downloader,
downloader_audio: DownloaderAudio,
lrc_only: bool = False,
no_lrc: bool = False,
):
self.downloader = downloader
self.__dict__.update(downloader_audio.__dict__)
self.lrc_only = lrc_only
self.no_lrc = no_lrc
@@ -75,6 +75,7 @@ class DownloaderSong:
"isrc": external_ids.get("isrc") if external_ids is not None else None,
"label": album_metadata.get("label"),
"lyrics": lyrics_unsynced,
"media_type": "Song",
"producer": (
self.downloader.get_artist_string(producers) if producers else None
),
@@ -135,9 +136,7 @@ class DownloaderSong:
try:
self._download(*args, **kwargs)
finally:
if self.downloader.temp_path.exists():
logger.debug(f'Cleaning up "{self.downloader.temp_path}"')
self.downloader.cleanup_temp_path()
self.downloader.cleanup_temp_path()
def _download(
self,
@@ -145,7 +144,7 @@ class DownloaderSong:
track_metadata: dict = None,
album_metadata: dict = None,
gid_metadata: dict = None,
stream_info: StreamInfo = None,
stream_info: StreamInfoAudio = None,
playlist_metadata: dict = None,
playlist_track: int = None,
decryption_key: bytes = None,
@@ -163,14 +162,14 @@ class DownloaderSong:
gid_metadata = self.downloader.get_gid_metadata(track_id, "track")
if not stream_info:
logger.debug("Getting stream info")
stream_info = self.downloader.get_stream_info(gid_metadata, "track")
stream_info = self.get_stream_info(gid_metadata, "track")
if not stream_info.file_id:
logger.warning(
"Track is not available on Spotify's "
"servers and no alternative found, skipping"
)
return
if stream_info.quality != self.downloader.quality:
if stream_info.quality != self.audio_quality:
logger.warning(f"Quality has been changed to {stream_info.quality.value}")
if gid_metadata.get("has_lyrics"):
logger.debug("Getting lyrics")
@@ -193,15 +192,17 @@ class DownloaderSong:
playlist_track,
),
}
file_extension = self.get_file_extension()
final_path = self.downloader.get_final_path(
"track",
tags,
".ogg",
file_extension,
)
lrc_path = self.downloader.get_lrc_path(final_path)
cover_path = self.get_cover_path(final_path)
cover_url = self.downloader.get_cover_url(album_metadata)
decrypted_path = None
remuxed_path = None
if self.lrc_only:
pass
elif final_path.exists() and not self.downloader.overwrite:
@@ -209,16 +210,32 @@ class DownloaderSong:
else:
if not decryption_key:
logger.debug("Getting decryption key")
decryption_key = self.downloader.get_decryption_key(stream_info.file_id)
encrypted_path = self.downloader.get_encrypted_path(track_id)
decrypted_path = self.downloader.get_decrypted_path(track_id)
decryption_key = self.get_decryption_key(stream_info)
encrypted_path = self.downloader.get_file_temp_path(
track_id,
"_encrypted",
file_extension,
)
decrypted_path = self.downloader.get_file_temp_path(
track_id,
"_decrypted",
file_extension,
)
remuxed_path = self.downloader.get_file_temp_path(
track_id,
"_remuxed",
file_extension,
)
logger.debug(f'Downloading to "{encrypted_path}"')
self.downloader.download_stream_url(encrypted_path, stream_info.stream_url)
logger.debug(f'Decrypting to "{decrypted_path}"')
self.downloader.decrypt(
self.download_stream_url(encrypted_path, stream_info.stream_url)
logger.debug(
f'Decrypting to "{decrypted_path}" and remuxing to "{remuxed_path}"'
)
self.decrypt(
decryption_key,
encrypted_path,
decrypted_path,
remuxed_path,
)
if self.no_lrc or not lyrics.synced:
pass
@@ -227,26 +244,17 @@ class DownloaderSong:
else:
logger.debug(f'Saving synced lyrics to "{lrc_path}"')
self.downloader.save_lrc(lrc_path, lyrics.synced)
if (
self.downloader.save_cover
and cover_path.exists()
and not self.downloader.overwrite
):
logger.debug(f'Cover already exists at "{cover_path}", skipping')
return
elif self.downloader.save_cover and cover_url is not None:
logger.debug(f'Saving cover to "{cover_path}"')
self.downloader.save_cover_file(cover_path, cover_url)
if decrypted_path:
logger.debug("Applying tags")
self.downloader.apply_tags(decrypted_path, tags, cover_url)
logger.debug(f'Moving to "{final_path}"')
self.downloader.move_to_final_path(decrypted_path, final_path)
if not self.lrc_only and self.downloader.save_playlist and playlist_metadata:
playlist_file_path = self.downloader.get_playlist_file_path(tags)
logger.debug(f'Updating M3U8 playlist from "{playlist_file_path}"')
self.downloader.update_playlist_file(
playlist_file_path,
final_path,
playlist_track,
)
media_temp_path = (
remuxed_path
if remuxed_path is not None and remuxed_path.exists()
else decrypted_path
)
self.downloader._final_processing(
cover_path,
cover_url,
media_temp_path,
final_path,
tags,
playlist_metadata,
playlist_track,
)
+400
View File
@@ -0,0 +1,400 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from yt_dlp.downloader.fragment import FragmentFD
from yt_dlp.YoutubeDL import YoutubeDL
from .downloader import Downloader
from .enums import RemuxModeAudio, RemuxModeVideo, VideoFormat
from .models import StreamInfoVideo
class DownloaderVideo:
def __init__(
self,
downloader: Downloader,
video_format: VideoFormat = VideoFormat.MP4,
remux_mode: RemuxModeVideo = RemuxModeVideo.FFMPEG,
):
self.downloader = downloader
self.video_format = video_format
self.remux_mode = remux_mode
self._adjust_remux_mode()
def _adjust_remux_mode(self):
if self.video_format == VideoFormat.WEBM:
self.remux_mode = RemuxModeVideo.FFMPEG
def get_cover_path(self, final_path: Path) -> Path:
return final_path.with_suffix(".jpg")
def get_file_extension(
self,
file_type_video: str,
file_type_audio: str,
) -> str:
if file_type_video == file_type_audio:
return "." + file_type_video
else:
return ".mp4"
def get_encryption_info(
self,
encryption_infos: list[dict],
key_system: str,
) -> tuple[int, str]:
for index, encryption_info in enumerate(encryption_infos):
if encryption_info["key_system"] == key_system:
return index, encryption_info
def get_stream_info(
self,
gid: str,
) -> StreamInfoVideo:
stream_info = StreamInfoVideo()
manifest = self.downloader.spotify_api.get_video_manifest(gid)
base_url = manifest["base_urls"][0]
initialization_template = manifest["initialization_template"]
segment_template = manifest["segment_template"]
end_time_millis = manifest["end_time_millis"]
segment_length = manifest["contents"][0]["segment_length"]
encryption_index = None
if manifest["contents"][0].get("encryption_infos"):
encryption_index, encryption_info = self.get_encryption_info(
manifest["contents"][0]["encryption_infos"],
"widevine",
)
stream_info.encryption_data_widevine = encryption_info["encryption_data"]
video_profiles = list(
profile
for profile in manifest["contents"][0]["profiles"]
if profile["mime_type"].startswith("video")
and encryption_index
in (profile.get("encryption_indices", [encryption_index]))
)
video_profiles = list(
filter(
lambda x: x["mime_type"].startswith("video")
and encryption_index
in (x.get("encryption_indices", [encryption_index])),
manifest["contents"][0]["profiles"],
)
)
audio_profiles = list(
filter(
lambda x: x["mime_type"].startswith("audio")
and encryption_index
in (x.get("encryption_indices", [encryption_index])),
manifest["contents"][0]["profiles"],
)
)
if not video_profiles or not audio_profiles:
return stream_info
if self.video_format == VideoFormat.ASK:
profile_video, profile_audio = (
self.get_video_profile_from_user(
video_profiles,
),
self.get_audio_profile_from_user(
audio_profiles,
),
)
else:
profile_video, profile_audio = (
self.get_best_profile_by_bitrate(
video_profiles,
f"video/{self.video_format.value}",
),
self.get_best_profile_by_bitrate(
audio_profiles,
f"audio/{self.video_format.value}",
),
)
profile_id_video = profile_video["id"]
file_type_video = profile_video["file_type"]
profile_id_audio = profile_audio["id"]
file_type_audio = profile_audio["file_type"]
stream_info.segment_urls_video = self.get_segment_urls(
base_url,
initialization_template,
segment_template,
end_time_millis,
segment_length,
profile_id_video,
file_type_video,
)
stream_info.segment_urls_audio = self.get_segment_urls(
base_url,
initialization_template,
segment_template,
end_time_millis,
segment_length,
profile_id_audio,
file_type_audio,
)
stream_info.file_type_video = file_type_video
stream_info.file_type_audio = file_type_audio
return stream_info
def get_best_profile_by_bitrate(
self,
profiles: list[dict],
mime_type: str,
) -> str:
profiles_filtered = list(
filter(
lambda x: x["mime_type"] == mime_type,
profiles,
)
)
bitrate_key = f"{mime_type.split('/')[0]}_bitrate"
best_profile = max(profiles_filtered, key=lambda x: x[bitrate_key])
return best_profile
def get_video_profile_from_user(
self,
video_profiles: list[dict],
) -> str:
choices = [
Choice(
name=" | ".join(
[
profile["video_codec"],
f"{profile['video_width']}x{profile['video_height']}",
str(profile["video_bitrate"]),
]
),
value=profile,
)
for profile in video_profiles
]
selected = inquirer.select(
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
choices=choices,
).execute()
return selected
def get_audio_profile_from_user(
self,
audio_profiles: list[dict],
) -> str:
choices = [
Choice(
name=" | ".join(
[
profile["audio_codec"],
str(profile["audio_bitrate"]),
]
),
value=profile,
)
for profile in audio_profiles
]
selected = inquirer.select(
message="Select which audio codec to download: (Codec | Bitrate)",
choices=choices,
).execute()
return selected
def get_segment_urls(
self,
base_url: str,
initialization_template: str,
segment_template: str,
end_time_millis: int,
segment_length: int,
profile_id: int,
file_type: str,
) -> list[str]:
initialization_template_formatted = initialization_template.replace(
"{{profile_id}}", str(profile_id)
).replace("{{file_type}}", file_type)
segment_urls = []
first_segment = base_url + initialization_template_formatted
segment_urls.append(first_segment)
for i in range(0, int(end_time_millis / 1000) + 5, segment_length):
segment_template_url_formatted = (
segment_template.replace("{{profile_id}}", str(profile_id))
.replace("{{segment_timestamp}}", str(i))
.replace("{{file_type}}", file_type)
)
segment_urls.append(base_url + segment_template_url_formatted)
return segment_urls
def download_segments(
self,
segment_urls: list[str],
input_path: Path,
):
input_path.parent.mkdir(parents=True, exist_ok=True)
segments_dict = [
{"url": url, "frag_index": idx + 1} for idx, url in enumerate(segment_urls)
]
ctx = {
"filename": str(input_path),
"total_frags": len(segments_dict),
}
info_dict = {}
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"noprogress": self.downloader.silence,
},
) as ydl:
try:
fragment_downloader = FragmentFD(ydl, ydl.params)
fragment_downloader._prepare_and_start_frag_download(
ctx,
info_dict,
)
fragment_downloader.download_and_append_fragments(
ctx,
segments_dict,
info_dict,
)
finally:
fragment_downloader._finish_multiline_status()
def remux(
self,
decrypted_path_video: Path,
decrypted_path_audio: Path,
remuxed_path: Path,
key_id: str | None = None,
decryption_key: str | None = None,
encrypted_path_video: Path | None = None,
encrypted_path_audio: Path | None = None,
):
if decryption_key:
if remuxed_path.suffix == ".webm":
self.decrypt_packager(
key_id,
decryption_key,
encrypted_path_video,
decrypted_path_video,
)
self.decrypt_packager(
key_id,
decryption_key,
encrypted_path_audio,
decrypted_path_audio,
)
else:
self.decrypt_mp4decrypt(
decryption_key,
encrypted_path_video,
decrypted_path_video,
)
self.decrypt_mp4decrypt(
decryption_key,
encrypted_path_audio,
decrypted_path_audio,
)
if self.remux_mode == RemuxModeAudio.MP4BOX:
self.remux_mp4box(
decrypted_path_video,
decrypted_path_audio,
remuxed_path,
)
else:
self.remux_ffmpeg(
decrypted_path_video,
decrypted_path_audio,
remuxed_path,
)
def decrypt_packager(
self,
key_id: str,
decryption_key: str,
encrypted_path: Path,
decrypted_path: Path,
):
subprocess.run(
[
self.downloader.packager_path_full,
"--quiet",
f"stream=0,in={encrypted_path},output={decrypted_path}",
"-enable_raw_key_decryption",
"-keys",
f"key_id={key_id}:key={decryption_key}",
],
check=True,
**self.downloader.subprocess_additional_args,
)
def decrypt_mp4decrypt(
self,
decryption_key: str,
encrypted_path: Path,
decrypted_path: Path,
):
subprocess.run(
[
self.downloader.mp4decrypt_path_full,
"--key",
f"1:{decryption_key}",
encrypted_path,
decrypted_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_ffmpeg(
self,
input_path_video: Path,
input_path_audio: Path,
output_path: Path,
):
output_path.parent.mkdir(parents=True, exist_ok=True)
subprocess.run(
[
self.downloader.ffmpeg_path_full,
"-loglevel",
"error",
"-y",
"-i",
input_path_video,
"-i",
input_path_audio,
"-c",
"copy",
"-map",
"0:v:0",
"-map",
"1:a:0",
output_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
def remux_mp4box(
self,
decrypted_path_video: Path,
decrypted_path_audio: Path,
remuxed_path: Path,
):
subprocess.run(
[
self.downloader.mp4box_path_full,
"-quiet",
"-itags",
"artist=placeholder",
"-keep-utc",
"-add",
decrypted_path_video,
"-add",
decrypted_path_audio,
"-new",
remuxed_path,
],
check=True,
**self.downloader.subprocess_additional_args,
)
+23 -4
View File
@@ -1,12 +1,31 @@
from enum import Enum
class Quality(Enum):
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
class AudioQuality(Enum):
VORBIS_HIGH = "vorbis-high"
VORBIS_MEDIUM = "vorbis-medium"
VORBIS_LOW = "vorbis-low"
AAC_MEDIUM = "aac-medium"
AAC_HIGH = "aac-high"
class VideoFormat(Enum):
MP4 = "mp4"
WEBM = "webm"
ASK = "ask"
class DownloadMode(Enum):
YTDLP = "ytdlp"
ARIA2C = "aria2c"
class RemuxModeAudio(Enum):
FFMPEG = "ffmpeg"
MP4BOX = "mp4box"
MP4DECRYPT = "mp4decrypt"
class RemuxModeVideo(Enum):
FFMPEG = "ffmpeg"
MP4BOX = "mp4box"
+13 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass
from .enums import Quality
from .enums import AudioQuality
@dataclass
@@ -26,7 +26,17 @@ class DownloadQueueItem:
@dataclass
class StreamInfo:
class StreamInfoAudio:
stream_url: str = None
file_id: str = None
quality: Quality = None
widevine_pssh: str = None
quality: AudioQuality = None
@dataclass
class StreamInfoVideo:
segment_urls_video: list[str] = None
segment_urls_audio: list[str] = None
file_type_video: str = None
file_type_audio: str = None
encryption_data_widevine: str = None
+57
View File
@@ -20,9 +20,15 @@ class SpotifyApi:
LYRICS_API_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/{track_id}"
METADATA_API_URL = "https://api.spotify.com/v1/{type}/{item_id}"
GID_METADATA_API_URL = "https://spclient.wg.spotify.com/metadata/4/{media_type}/{gid}?market=from_token"
PATHFINDER_API_URL = "https://api-partner.spotify.com/pathfinder/v1/query"
VIDEO_MANIFEST_API_URL = "https://gue1-spclient.spotify.com/manifests/v7/json/sources/{gid}/options/supports_drm"
PLAYPLAY_LICENSE_API_URL = (
"https://gew4-spclient.spotify.com/playplay/v1/key/{file_id}"
)
WIDEVINE_LICENSE_API_URL = (
"https://gue1-spclient.spotify.com/widevine-license/v1/{type}/license"
)
SEEK_TABLE_API_URL = "https://seektables.scdn.co/seektable/{file_id}.json"
TRACK_CREDITS_API_URL = "https://spclient.wg.spotify.com/track-credits-view/v0/experimental/{track_id}/credits"
STREAM_URLS_API_URL = (
"https://gue1-spclient.spotify.com/storage-resolve/v2/files/audio/interactive/11/"
@@ -253,6 +259,21 @@ class SpotifyApi:
)
return artist_albums
def get_video_manifest(
self,
gid: str,
) -> dict:
self._refresh_session_auth()
response = self.session.get(self.VIDEO_MANIFEST_API_URL.format(gid=gid))
check_response(response)
return response.json()
def get_seek_table(self, file_id: str) -> dict:
self._refresh_session_auth()
response = self.session.get(self.SEEK_TABLE_API_URL.format(file_id=file_id))
check_response(response)
return response.json()
def get_playplay_license(self, file_id: str, challenge: bytes) -> bytes:
self._refresh_session_auth()
response = self.session.post(
@@ -262,8 +283,44 @@ class SpotifyApi:
check_response(response)
return response.content
def get_widevine_license(self, challenge: bytes, media_type: str) -> bytes:
self._refresh_session_auth()
response = self.session.post(
self.WIDEVINE_LICENSE_API_URL.format(type=media_type),
challenge,
)
check_response(response)
return response.content
def get_stream_urls(self, file_id: str) -> str:
self._refresh_session_auth()
response = self.session.get(self.STREAM_URLS_API_URL.format(file_id=file_id))
check_response(response)
return response.json()
def get_now_playing_view(self, track_id: str, artist_id: str) -> dict:
self._refresh_session_auth()
response = self.session.get(
self.PATHFINDER_API_URL,
params={
"operationName": "queryNpvArtist",
"variables": json.dumps(
{
"artistUri": f"spotify:artist:{artist_id}",
"trackUri": f"spotify:track:{track_id}",
"enableCredits": True,
"enableRelatedVideos": True,
}
),
"extensions": json.dumps(
{
"persistedQuery": {
"version": 1,
"sha256Hash": "4ec4ae302c609a517cab6b8868f601cd3457c751c570ab12e988723cc036284f",
}
}
),
},
)
check_response(response)
return response.json()