Files
votify/votify/cli.py
T
2024-10-31 13:35:09 -03:00

637 lines
20 KiB
Python

from __future__ import annotations
import inspect
import json
import logging
import time
from enum import Enum
from pathlib import Path
import click
from . import __version__
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 .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:
if isinstance(param.default, Enum):
return param.default.value
elif isinstance(param.default, Path):
return str(param.default)
else:
return param.default
def write_default_config_file(ctx: click.Context) -> None:
ctx.params["config_path"].parent.mkdir(parents=True, exist_ok=True)
config_file = {
param.name: get_param_string(param)
for param in ctx.command.params
if param.name not in EXCLUDED_CONFIG_FILE_PARAMS
}
ctx.params["config_path"].write_text(json.dumps(config_file, indent=4))
def load_config_file(
ctx: click.Context,
param: click.Parameter,
no_config_file: bool,
) -> click.Context:
if no_config_file:
return ctx
if not ctx.params["config_path"].exists():
write_default_config_file(ctx)
config_file = dict(json.loads(ctx.params["config_path"].read_text()))
for param in ctx.command.params:
if (
config_file.get(param.name) is not None
and not ctx.get_parameter_source(param.name)
== click.core.ParameterSource.COMMANDLINE
):
ctx.params[param.name] = param.type_cast_value(ctx, config_file[param.name])
return ctx
@click.command()
@click.help_option("-h", "--help")
@click.version_option(__version__, "-v", "--version")
# CLI specific options
@click.argument(
"urls",
nargs=-1,
type=str,
required=True,
)
@click.option(
"--wait-interval",
"-w",
type=float,
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",
is_flag=True,
help="Force to detect the account as premium.",
)
@click.option(
"--read-urls-as-txt",
"-r",
is_flag=True,
help="Interpret URLs as paths to text files containing URLs.",
)
@click.option(
"--config-path",
type=Path,
default=Path.home() / ".votify" / "config.json",
help="Path to config file.",
)
@click.option(
"--log-level",
type=str,
default="INFO",
help="Log level.",
)
@click.option(
"--no-exceptions",
is_flag=True,
help="Don't print exceptions.",
)
@click.option(
"--cookies-path",
type=Path,
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",
"-o",
type=Path,
default=downloader_sig.parameters["output_path"].default,
help="Path to output directory.",
)
@click.option(
"--temp-path",
type=Path,
default=downloader_sig.parameters["temp_path"].default,
help="Path to temporary directory.",
)
@click.option(
"--wvd-path",
type=Path,
default=downloader_sig.parameters["wvd_path"].default,
help="Path to .wvd file.",
)
@click.option(
"--aria2c-path",
type=str,
default=downloader_sig.parameters["aria2c_path"].default,
help="Path to aria2c binary.",
)
@click.option(
"--ffmpeg-path",
type=str,
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",
type=str,
default=downloader_sig.parameters["template_folder_album"].default,
help="Template folder for tracks that are part of an album.",
)
@click.option(
"--template-folder-compilation",
type=str,
default=downloader_sig.parameters["template_folder_compilation"].default,
help="Template folder for tracks that are part of a compilation album.",
)
@click.option(
"--template-file-single-disc",
type=str,
default=downloader_sig.parameters["template_file_single_disc"].default,
help="Template file for the tracks that are part of a single-disc album.",
)
@click.option(
"--template-file-multi-disc",
type=str,
default=downloader_sig.parameters["template_file_multi_disc"].default,
help="Template file for the tracks that are part of a multi-disc album.",
)
@click.option(
"--template-folder-episode",
type=str,
default=downloader_sig.parameters["template_folder_episode"].default,
help="Template folder for episodes (podcasts).",
)
@click.option(
"--template-file-episode",
type=str,
default=downloader_sig.parameters["template_file_episode"].default,
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",
type=str,
default=downloader_sig.parameters["template_file_playlist"].default,
help="Template file for the M3U8 playlist.",
)
@click.option(
"--date-tag-template",
type=str,
default=downloader_sig.parameters["date_tag_template"].default,
help="Date tag template.",
)
@click.option(
"--save-cover",
is_flag=True,
help="Save cover as a separate file.",
)
@click.option(
"--save-playlist",
is_flag=True,
help="Save a M3U8 playlist file when downloading a playlist.",
)
@click.option(
"--overwrite",
is_flag=True,
help="Overwrite existing files.",
)
@click.option(
"--exclude-tags",
type=str,
default=downloader_sig.parameters["exclude_tags"].default,
help="Comma-separated tags to exclude.",
)
@click.option(
"--truncate",
type=int,
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",
"-l",
is_flag=True,
help="Download only the synced lyrics.",
)
@click.option(
"--no-lrc",
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",
"-n",
is_flag=True,
callback=load_config_file,
help="Do not use a 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,
no_exceptions: bool,
cookies_path: Path,
cookies_browser: str,
output_path: Path,
temp_path: Path,
wvd_path: Path,
download_mode: DownloadMode,
aria2c_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,
save_playlist: bool,
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(
format="[%(levelname)-8s %(asctime)s] %(message)s",
datefmt="%H:%M:%S",
)
logger.setLevel(log_level)
logger.info("Starting Votify")
if not cookies_path.exists():
logger.critical(X_NOT_FOUND_STRING.format("Cookies file", cookies_path))
spotify_api = SpotifyApi.from_cookies_file(cookies_path)
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,
temp_path,
wvd_path,
aria2c_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,
save_playlist,
overwrite,
exclude_tags,
truncate,
)
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 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
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.MP4:
music_video_warning_message.append(
X_NOT_FOUND_STRING.format("mp4decrypt", mp4decrypt_path)
)
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)
)
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 = []
for url in urls:
if Path(url).exists():
_urls.extend(Path(url).read_text(encoding="utf-8").splitlines())
urls = _urls
for url_index, url in enumerate(urls, start=1):
url_progress = f"URL {url_index}/{len(urls)}"
logger.info(f'({url_progress}) Checking "{url}"')
try:
url_info = downloader.get_url_info(url)
if url_info.type == "artist":
download_queue = downloader.get_download_queue_from_artist(url_info.id)
else:
download_queue = downloader.get_download_queue(
url_info.type,
url_info.id,
)
except Exception as e:
error_count += 1
logger.error(
f'({url_progress}) Failed to check "{url}"',
exc_info=no_exceptions,
)
continue
for index, download_queue_item in enumerate(download_queue, start=1):
queue_progress = (
f"Track {index}/{len(download_queue)} from URL {url_index}/{len(urls)}"
)
media_metadata = download_queue_item.media_metadata
try:
logger.info(
f'({queue_progress}) Downloading "{media_metadata["name"]}"'
)
media_id = downloader.get_media_id(media_metadata)
media_type = media_metadata["type"]
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":
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=not no_exceptions,
)
finally:
if wait_interval > 0 and index != len(download_queue):
logger.debug(
f"Waiting for {wait_interval} second(s) before continuing"
)
time.sleep(wait_interval)
logger.info(f"Done ({error_count} error(s))")