mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 04:05:14 +03:00
574 lines
17 KiB
Python
574 lines
17 KiB
Python
import inspect
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
import click
|
|
|
|
from .. import __version__
|
|
from ..api import AppleMusicApi
|
|
from ..downloader import (
|
|
AppleMusicBaseDownloader,
|
|
AppleMusicDownloader,
|
|
AppleMusicMusicVideoDownloader,
|
|
AppleMusicSongDownloader,
|
|
AppleMusicUploadedVideoDownloader,
|
|
CoverFormat,
|
|
DownloadItem,
|
|
DownloadMode,
|
|
MediaDownloadConfigurationError,
|
|
MediaFormatNotAvailableError,
|
|
MediaNotStreamableError,
|
|
RemuxFormatMusicVideo,
|
|
RemuxMode,
|
|
)
|
|
from ..interface import (
|
|
MusicVideoCodec,
|
|
MusicVideoResolution,
|
|
SongCodec,
|
|
SyncedLyricsFormat,
|
|
UploadedVideoQuality,
|
|
)
|
|
from .constants import X_NOT_IN_PATH
|
|
from .custom_logger_formatter import CustomLoggerFormatter
|
|
from .utils import Csv, PathPrompt, load_config_file, make_sync
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
api_sig = inspect.signature(AppleMusicApi.from_netscape_cookies)
|
|
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
|
|
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
|
|
song_downloader_sig = inspect.signature(AppleMusicSongDownloader.__init__)
|
|
uploaded_video_downloader_sig = inspect.signature(
|
|
AppleMusicUploadedVideoDownloader.__init__
|
|
)
|
|
|
|
|
|
@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(
|
|
"--read-urls-as-txt",
|
|
"-r",
|
|
is_flag=True,
|
|
help="Interpret URLs as paths to text files containing URLs separated by newlines",
|
|
)
|
|
@click.option(
|
|
"--config-path",
|
|
type=click.Path(file_okay=True, dir_okay=False, writable=True, resolve_path=True),
|
|
default=str(Path.home() / ".gamdl" / "config.ini"),
|
|
help="Path to config file.",
|
|
)
|
|
@click.option(
|
|
"--log-level",
|
|
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
|
|
default="INFO",
|
|
help="Log level.",
|
|
)
|
|
@click.option(
|
|
"--log-file",
|
|
type=click.Path(file_okay=True, dir_okay=False, writable=True, resolve_path=True),
|
|
default=None,
|
|
help="Path to log file.",
|
|
)
|
|
@click.option(
|
|
"--no-exceptions",
|
|
is_flag=True,
|
|
help="Don't print exceptions.",
|
|
)
|
|
# API specific options
|
|
@click.option(
|
|
"--cookies-path",
|
|
"-c",
|
|
type=PathPrompt(is_file=True),
|
|
default=api_sig.parameters["cookies_path"].default,
|
|
help="Path to .txt cookies file.",
|
|
)
|
|
@click.option(
|
|
"--language",
|
|
"-l",
|
|
type=str,
|
|
default=api_sig.parameters["language"].default,
|
|
help="Metadata language as an ISO-2A language code (don't always work for videos).",
|
|
)
|
|
# Base Downloader specific options
|
|
@click.option(
|
|
"--output-path",
|
|
"-o",
|
|
type=click.Path(file_okay=False, dir_okay=True, writable=True, resolve_path=True),
|
|
default=base_downloader_sig.parameters["output_path"].default,
|
|
help="Path to output directory.",
|
|
)
|
|
@click.option(
|
|
"--temp-path",
|
|
type=click.Path(file_okay=False, dir_okay=True, writable=True, resolve_path=True),
|
|
default=base_downloader_sig.parameters["temp_path"].default,
|
|
help="Path to temporary directory.",
|
|
)
|
|
@click.option(
|
|
"--wvd-path",
|
|
type=click.Path(file_okay=False, dir_okay=True, writable=True, resolve_path=True),
|
|
default=base_downloader_sig.parameters["wvd_path"].default,
|
|
help="Path to .wvd file.",
|
|
)
|
|
@click.option(
|
|
"--overwrite",
|
|
is_flag=True,
|
|
help="Overwrite existing files.",
|
|
default=base_downloader_sig.parameters["overwrite"].default,
|
|
)
|
|
@click.option(
|
|
"--save-cover",
|
|
"-s",
|
|
is_flag=True,
|
|
help="Save cover as a separate file.",
|
|
default=base_downloader_sig.parameters["save_cover"].default,
|
|
)
|
|
@click.option(
|
|
"--save-playlist",
|
|
is_flag=True,
|
|
help="Save a M3U8 playlist file when downloading a playlist.",
|
|
default=base_downloader_sig.parameters["save_playlist"].default,
|
|
)
|
|
@click.option(
|
|
"--nm3u8dlre-path",
|
|
type=str,
|
|
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
|
|
help="Path to N_m3u8DL-RE binary.",
|
|
)
|
|
@click.option(
|
|
"--mp4decrypt-path",
|
|
type=str,
|
|
default=base_downloader_sig.parameters["mp4decrypt_path"].default,
|
|
help="Path to mp4decrypt binary.",
|
|
)
|
|
@click.option(
|
|
"--ffmpeg-path",
|
|
type=str,
|
|
default=base_downloader_sig.parameters["ffmpeg_path"].default,
|
|
help="Path to FFmpeg binary.",
|
|
)
|
|
@click.option(
|
|
"--mp4box-path",
|
|
type=str,
|
|
default=base_downloader_sig.parameters["mp4box_path"].default,
|
|
help="Path to MP4Box binary.",
|
|
)
|
|
@click.option(
|
|
"--download-mode",
|
|
type=DownloadMode,
|
|
default=base_downloader_sig.parameters["download_mode"].default,
|
|
help="Download mode.",
|
|
)
|
|
@click.option(
|
|
"--remux-mode",
|
|
type=RemuxMode,
|
|
default=base_downloader_sig.parameters["remux_mode"].default,
|
|
help="Remux mode.",
|
|
)
|
|
@click.option(
|
|
"--cover-format",
|
|
type=CoverFormat,
|
|
default=base_downloader_sig.parameters["cover_format"].default,
|
|
help="Cover format.",
|
|
)
|
|
@click.option(
|
|
"--album-folder-template",
|
|
type=str,
|
|
default=base_downloader_sig.parameters["album_folder_template"].default,
|
|
help="Template folder for tracks that are part of an album.",
|
|
)
|
|
@click.option(
|
|
"--compilation-folder-template",
|
|
type=str,
|
|
default=base_downloader_sig.parameters["compilation_folder_template"].default,
|
|
help="Template folder for tracks that are part of a compilation album.",
|
|
)
|
|
@click.option(
|
|
"--single-disc-folder-template",
|
|
type=str,
|
|
default=base_downloader_sig.parameters["single_disc_folder_template"].default,
|
|
help="Template file for the tracks that are part of a single-disc album.",
|
|
)
|
|
@click.option(
|
|
"--multi-disc-folder-template",
|
|
type=str,
|
|
default=base_downloader_sig.parameters["multi_disc_folder_template"].default,
|
|
help="Template file for the tracks that are part of a multi-disc album.",
|
|
)
|
|
@click.option(
|
|
"--no-album-folder-template",
|
|
type=str,
|
|
default=base_downloader_sig.parameters["no_album_folder_template"].default,
|
|
help="Template folder for the tracks that are not part of an album.",
|
|
)
|
|
@click.option(
|
|
"--no-album-file-template",
|
|
type=str,
|
|
default=base_downloader_sig.parameters["no_album_file_template"].default,
|
|
help="Template file for the tracks that are not part of an album.",
|
|
)
|
|
@click.option(
|
|
"--playlist-file-template",
|
|
type=str,
|
|
default=base_downloader_sig.parameters["playlist_file_template"].default,
|
|
help="Template file for the M3U8 playlist.",
|
|
)
|
|
@click.option(
|
|
"--date-tag-template",
|
|
type=str,
|
|
default=base_downloader_sig.parameters["date_tag_template"].default,
|
|
help="Date tag template.",
|
|
)
|
|
@click.option(
|
|
"--exclude-tags",
|
|
type=Csv(str),
|
|
default=base_downloader_sig.parameters["exclude_tags"].default,
|
|
help="Comma-separated tags to exclude.",
|
|
)
|
|
@click.option(
|
|
"--cover-size",
|
|
type=int,
|
|
default=base_downloader_sig.parameters["cover_size"].default,
|
|
help="Cover size.",
|
|
)
|
|
@click.option(
|
|
"--truncate",
|
|
type=int,
|
|
default=base_downloader_sig.parameters["truncate"].default,
|
|
help="Maximum length of the file/folder names.",
|
|
)
|
|
# DownloaderSong specific options
|
|
@click.option(
|
|
"--codec-song",
|
|
type=SongCodec,
|
|
default=song_downloader_sig.parameters["codec"].default,
|
|
help="Song codec.",
|
|
)
|
|
@click.option(
|
|
"--synced-lyrics-format",
|
|
type=SyncedLyricsFormat,
|
|
default=song_downloader_sig.parameters["synced_lyrics_format"].default,
|
|
help="Synced lyrics format.",
|
|
)
|
|
@click.option(
|
|
"--no-synced-lyrics",
|
|
is_flag=True,
|
|
help="Don't download the synced lyrics.",
|
|
default=song_downloader_sig.parameters["no_synced_lyrics"].default,
|
|
)
|
|
@click.option(
|
|
"--synced-lyrics-only",
|
|
is_flag=True,
|
|
help="Download only the synced lyrics.",
|
|
default=song_downloader_sig.parameters["synced_lyrics_only"].default,
|
|
)
|
|
# DownloaderMusicVideo specific options
|
|
@click.option(
|
|
"--music-video-codec-priority",
|
|
type=Csv(MusicVideoCodec),
|
|
default=music_video_downloader_sig.parameters["codec_priority"].default,
|
|
help="Comma-separated music video codec priority.",
|
|
)
|
|
@click.option(
|
|
"--music-video-remux-format",
|
|
type=RemuxFormatMusicVideo,
|
|
default=music_video_downloader_sig.parameters["remux_format"].default,
|
|
help="Music video remux format.",
|
|
)
|
|
@click.option(
|
|
"--music-video-resolution",
|
|
type=MusicVideoResolution,
|
|
default=music_video_downloader_sig.parameters["resolution"].default,
|
|
help="Target video resolution for music videos.",
|
|
)
|
|
# DownloaderUploadedVideo specific options
|
|
@click.option(
|
|
"--uploaded-video-quality",
|
|
type=UploadedVideoQuality,
|
|
default=uploaded_video_downloader_sig.parameters["quality"].default,
|
|
help="Upload videos quality.",
|
|
)
|
|
# 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.",
|
|
)
|
|
@make_sync
|
|
async def main(
|
|
urls: list[str],
|
|
read_urls_as_txt: bool,
|
|
config_path: str,
|
|
log_level: str,
|
|
log_file: str,
|
|
no_exceptions: bool,
|
|
cookies_path: str,
|
|
language: str,
|
|
output_path: str,
|
|
temp_path: str,
|
|
wvd_path: str,
|
|
overwrite: bool,
|
|
save_cover: bool,
|
|
save_playlist: bool,
|
|
nm3u8dlre_path: str,
|
|
mp4decrypt_path: str,
|
|
ffmpeg_path: str,
|
|
mp4box_path: str,
|
|
download_mode: DownloadMode,
|
|
remux_mode: RemuxMode,
|
|
cover_format: CoverFormat,
|
|
album_folder_template: str,
|
|
compilation_folder_template: str,
|
|
single_disc_folder_template: str,
|
|
multi_disc_folder_template: str,
|
|
no_album_folder_template: str,
|
|
no_album_file_template: str,
|
|
playlist_file_template: str,
|
|
date_tag_template: str,
|
|
exclude_tags: list[str],
|
|
cover_size: int,
|
|
truncate: int,
|
|
codec_song: SongCodec,
|
|
synced_lyrics_format: SyncedLyricsFormat,
|
|
no_synced_lyrics: bool,
|
|
synced_lyrics_only: bool,
|
|
music_video_codec_priority: list[MusicVideoCodec],
|
|
music_video_remux_format: RemuxFormatMusicVideo,
|
|
music_video_resolution: MusicVideoResolution,
|
|
uploaded_video_quality: UploadedVideoQuality,
|
|
*args,
|
|
**kwargs,
|
|
):
|
|
root_logger = logging.getLogger(__name__.split(".")[0])
|
|
root_logger.setLevel(log_level)
|
|
root_logger.propagate = False
|
|
|
|
stream_handler = logging.StreamHandler()
|
|
stream_handler.setFormatter(CustomLoggerFormatter())
|
|
root_logger.addHandler(stream_handler)
|
|
|
|
if log_file:
|
|
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
file_handler.setFormatter(CustomLoggerFormatter(use_colors=False))
|
|
root_logger.addHandler(file_handler)
|
|
|
|
logger.info(f"Starting Gamdl {__version__}")
|
|
|
|
api = AppleMusicApi.from_netscape_cookies(
|
|
cookies_path=cookies_path,
|
|
language=language,
|
|
)
|
|
await api.setup()
|
|
|
|
if not api.account_info["meta"]["subscription"]["active"]:
|
|
logger.critical(
|
|
"No active Apple Music subscription found, you won't be able to download"
|
|
" anything"
|
|
)
|
|
return
|
|
if api.account_info["data"][0]["attributes"].get("restrictions"):
|
|
logger.warning(
|
|
"Your account has content restrictions enabled, some content may not be"
|
|
" downloadable"
|
|
)
|
|
|
|
base_downloader = AppleMusicBaseDownloader(
|
|
apple_music_api=api,
|
|
output_path=output_path,
|
|
temp_path=temp_path,
|
|
wvd_path=wvd_path,
|
|
overwrite=overwrite,
|
|
save_cover=save_cover,
|
|
save_playlist=save_playlist,
|
|
nm3u8dlre_path=nm3u8dlre_path,
|
|
mp4decrypt_path=mp4decrypt_path,
|
|
ffmpeg_path=ffmpeg_path,
|
|
mp4box_path=mp4box_path,
|
|
download_mode=download_mode,
|
|
remux_mode=remux_mode,
|
|
cover_format=cover_format,
|
|
album_folder_template=album_folder_template,
|
|
compilation_folder_template=compilation_folder_template,
|
|
single_disc_folder_template=single_disc_folder_template,
|
|
multi_disc_folder_template=multi_disc_folder_template,
|
|
no_album_folder_template=no_album_folder_template,
|
|
no_album_file_template=no_album_file_template,
|
|
playlist_file_template=playlist_file_template,
|
|
date_tag_template=date_tag_template,
|
|
exclude_tags=exclude_tags,
|
|
cover_size=cover_size,
|
|
truncate=truncate,
|
|
)
|
|
base_downloader.setup()
|
|
|
|
song_downloader = AppleMusicSongDownloader(
|
|
base_downloader,
|
|
codec=codec_song,
|
|
synced_lyrics_format=synced_lyrics_format,
|
|
no_synced_lyrics=no_synced_lyrics,
|
|
synced_lyrics_only=synced_lyrics_only,
|
|
)
|
|
song_downloader.setup()
|
|
|
|
music_video_downloader = AppleMusicMusicVideoDownloader(
|
|
base_downloader,
|
|
codec_priority=music_video_codec_priority,
|
|
remux_format=music_video_remux_format,
|
|
resolution=music_video_resolution,
|
|
)
|
|
music_video_downloader.setup()
|
|
|
|
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
|
|
base_downloader,
|
|
quality=uploaded_video_quality,
|
|
)
|
|
uploaded_video_downloader.setup()
|
|
|
|
downloader = AppleMusicDownloader(
|
|
base_downloader,
|
|
song_downloader,
|
|
music_video_downloader,
|
|
uploaded_video_downloader,
|
|
)
|
|
|
|
if not synced_lyrics_only:
|
|
if not base_downloader.full_ffmpeg_path and (
|
|
remux_mode == RemuxMode.FFMPEG or download_mode == DownloadMode.NM3U8DLRE
|
|
):
|
|
logger.critical(X_NOT_IN_PATH.format("ffmpeg", ffmpeg_path))
|
|
return
|
|
|
|
if not base_downloader.full_mp4box_path and remux_mode == RemuxMode.MP4BOX:
|
|
logger.critical(X_NOT_IN_PATH.format("MP4Box", mp4box_path))
|
|
return
|
|
|
|
if (
|
|
not base_downloader.full_mp4decrypt_path
|
|
and codec_song
|
|
not in (
|
|
SongCodec.AAC_LEGACY,
|
|
SongCodec.AAC_HE_LEGACY,
|
|
)
|
|
or (
|
|
remux_mode == RemuxMode.MP4BOX
|
|
and not base_downloader.full_mp4decrypt_path
|
|
)
|
|
):
|
|
logger.critical(X_NOT_IN_PATH.format("mp4decrypt", mp4decrypt_path))
|
|
return
|
|
|
|
if (
|
|
download_mode == DownloadMode.NM3U8DLRE
|
|
and not base_downloader.full_nm3u8dlre_path
|
|
):
|
|
logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", nm3u8dlre_path))
|
|
return
|
|
|
|
if not base_downloader.full_mp4decrypt_path:
|
|
logger.warning(
|
|
X_NOT_IN_PATH.format("mp4decrypt", mp4decrypt_path)
|
|
+ ", music videos will not be downloaded"
|
|
)
|
|
downloader.skip_music_videos = True
|
|
|
|
if not codec_song.is_legacy():
|
|
logger.warning(
|
|
"You have chosen an experimental song codec. "
|
|
"They're not guaranteed to work due to API limitations."
|
|
)
|
|
|
|
if read_urls_as_txt:
|
|
urls_from_file = []
|
|
for url in urls:
|
|
if Path(url).is_file() and Path(url).exists():
|
|
urls_from_file.extend(
|
|
[
|
|
line.strip()
|
|
for line in Path(url).read_text(encoding="utf-8").splitlines()
|
|
if line.strip()
|
|
]
|
|
)
|
|
urls = urls_from_file
|
|
|
|
error_count = 0
|
|
for url_index, url in enumerate(urls, 1):
|
|
url_progress = click.style(f"[URL {url_index}/{len(urls)}]", dim=True)
|
|
logger.info(url_progress + f' Processing "{url}"')
|
|
download_queue = None
|
|
try:
|
|
url_info = downloader.get_url_info(url)
|
|
if not url_info:
|
|
logger.warning(
|
|
url_progress + f' Could not parse "{url}", skipping.',
|
|
)
|
|
continue
|
|
|
|
download_queue = await downloader.get_download_queue(url_info)
|
|
if not download_queue:
|
|
logger.warning(
|
|
url_progress
|
|
+ f' No downloadable media found for "{url}", skipping.',
|
|
)
|
|
continue
|
|
except KeyboardInterrupt:
|
|
exit(1)
|
|
except Exception as e:
|
|
error_count += 1
|
|
logger.error(
|
|
url_progress + f' Error processing "{url}"',
|
|
exc_info=not no_exceptions,
|
|
)
|
|
|
|
if not download_queue:
|
|
continue
|
|
|
|
for download_index, download_item in enumerate(
|
|
download_queue,
|
|
1,
|
|
):
|
|
download_queue_progress = click.style(
|
|
f"[Track {download_index}/{len(download_queue)}]",
|
|
dim=True,
|
|
)
|
|
media_title = (
|
|
download_item.media_metadata["attributes"]["name"]
|
|
if isinstance(
|
|
download_item,
|
|
DownloadItem,
|
|
)
|
|
else "Unknown Title"
|
|
)
|
|
logger.info(download_queue_progress + f' Downloading "{media_title}"')
|
|
|
|
try:
|
|
await downloader.download(download_item)
|
|
except (
|
|
FileExistsError,
|
|
MediaNotStreamableError,
|
|
MediaFormatNotAvailableError,
|
|
MediaDownloadConfigurationError,
|
|
) as e:
|
|
logger.warning(
|
|
download_queue_progress + f' Skipping "{media_title}": {e}'
|
|
)
|
|
continue
|
|
except KeyboardInterrupt:
|
|
exit(1)
|
|
except Exception as e:
|
|
error_count += 1
|
|
logger.error(
|
|
download_queue_progress + f' Error downloading "{media_title}"',
|
|
exc_info=not no_exceptions,
|
|
)
|
|
|
|
logger.info(f"Finished with {error_count} error(s)")
|