Compare commits

...

57 Commits

Author SHA1 Message Date
Rafael Moraes c636e4be33 Mark ALAC codec as unsupported in README 2025-11-11 22:06:09 -03:00
Rafael Moraes 1841a988e2 Handle empty lyrics in AppleMusicSongInterface 2025-11-11 22:04:55 -03:00
Rafael Moraes 8cdaa127d7 Bump version to 2.7.4 2025-11-11 22:02:39 -03:00
Rafael Moraes c31a6eee8e Increase Apple Music API client timeout to 60s 2025-11-11 22:02:14 -03:00
Rafael Moraes 00d301c23d Refactor track metadata extension logic 2025-11-11 22:02:02 -03:00
Rafael Moraes f05aa579d3 Increase HTTP transport retries to 10 2025-11-11 02:14:35 -03:00
Rafael Moraes 7e642ab2f3 Refactor path prompt logic in CLI utilities 2025-11-11 01:53:59 -03:00
Rafael Moraes c34f49faae Rename song codec CLI option for consistency 2025-11-06 15:49:20 -03:00
Rafael Moraes 78c3da5b8c Remove unused imports and parameters in README example 2025-11-06 15:48:13 -03:00
Rafael Moraes 00410aeb77 Fix README table row order for template options 2025-11-06 15:45:57 -03:00
Rafael Moraes 4211ab6f8c Fix option order for no_album_folder_template 2025-11-06 15:45:48 -03:00
Rafael Moraes 599c9140db Remove debug print from load_config_file 2025-11-06 12:57:23 -03:00
Rafael Moraes 73ab79beea Move utility functions from utils.py to cli.py
Relocated the load_config_file and make_sync functions from gamdl/cli/utils.py to gamdl/cli/cli.py to improve code organization and reduce unnecessary imports in utils.py.
2025-11-06 12:54:39 -03:00
Rafael Moraes 2dfed33fe2 Refactor config param default serialization logic 2025-11-06 12:54:23 -03:00
Rafael Moraes 4eb764af17 Update PathPrompt.convert to accept str type only 2025-11-05 23:57:35 -03:00
Rafael Moraes 6cdccf1f4f Refactor Csv param type to use Enum for subtype 2025-11-05 23:42:12 -03:00
Rafael Moraes a999271715 Update README with expanded usage example 2025-11-05 08:52:26 -03:00
Rafael Moraes 633674f45e Refactor MP4 tag generation in MediaTags 2025-11-05 08:49:02 -03:00
Rafael Moraes ceeef6b352 Skip Widevine decryption for ALAC codec 2025-11-02 12:56:19 -03:00
Rafael Moraes 8aa172185a Make CDM operations async using asyncio.to_thread 2025-10-30 12:37:12 -03:00
Rafael Moraes bdbaf7ca05 Make license challenge generation asynchronous 2025-10-30 12:37:05 -03:00
Rafael Moraes a9e1e02ebb Make license parsing asynchronous in AppleMusicInterface 2025-10-30 12:35:31 -03:00
Rafael Moraes 85619a3672 Refactor MP4 tagging to use apply_mp4_tags method 2025-10-30 12:31:16 -03:00
Rafael Moraes 15c1cc45f5 Rename GamdlBinaryNotFoundError to GamdlExecutableNotFoundError 2025-10-30 00:12:19 -03:00
Rafael Moraes b86e938185 Replace MediaDownloadConfigurationError with GamdlSyncedLyricsOnlyError 2025-10-30 00:05:38 -03:00
Rafael Moraes be4596798a Rename media error classes in CLI imports and usage 2025-10-29 23:56:31 -03:00
Rafael Moraes da8e49bd68 Refactor error handling and binary checks in downloader 2025-10-29 23:56:22 -03:00
Rafael Moraes 03c3b0e788 Refactor and add custom downloader exceptions 2025-10-29 23:56:15 -03:00
Rafael Moraes 3aca011b7d Refactor AppleMusicDownloader to remove Exception from return types 2025-10-29 23:30:27 -03:00
Rafael Moraes dfa38c6736 Add error field to DownloadItem dataclass 2025-10-29 23:30:18 -03:00
Rafael Moraes 48a8c940e1 Add error handling to download item methods 2025-10-29 23:30:12 -03:00
Rafael Moraes e80c776835 Bump version 2025-10-28 12:26:36 -03:00
Rafael Moraes 36e85098e5 Improve video playlist selection by codec priority 2025-10-28 12:25:09 -03:00
Rafael Moraes 7610768723 Change download method to return DownloadItem 2025-10-28 00:45:46 -03:00
Rafael Moraes 9afe027f5d Set video resolution in stream info 2025-10-28 00:32:33 -03:00
Rafael Moraes 4c5c43844a Add width and height to StreamInfo for video resolution 2025-10-28 00:32:29 -03:00
Rafael Moraes 025c89d85a Refactor flat filter handling in downloader 2025-10-27 23:09:50 -03:00
Rafael Moraes f8d1036c37 Add flat_filter support to AppleMusicDownloader 2025-10-27 23:01:17 -03:00
Rafael Moraes 0d8e6c4626 Add playlist_metadata and flat fields to DownloadItem 2025-10-27 22:59:29 -03:00
Rafael Moraes 5aff11bcae Add playlist metadata to download items 2025-10-27 22:59:23 -03:00
Rafael Moraes b5ce18ef26 Refactor CLI to use new Apple Music interfaces 2025-10-27 22:03:45 -03:00
Rafael Moraes 70346171b1 Refactor AppleMusicDownloader to use interface 2025-10-27 22:03:38 -03:00
Rafael Moraes 4a63070489 Refactor downloader classes to inherit from base 2025-10-27 22:03:30 -03:00
Rafael Moraes cb60eee694 Refactor interfaces to inherit from AppleMusicInterface 2025-10-27 22:03:19 -03:00
Rafael Moraes 955f649779 Fix cleanup logic in AppleMusicDownloader 2025-10-27 19:52:35 -03:00
Rafael Moraes c833f24fe2 Add skip_processing checks to AppleMusicDownloader 2025-10-27 19:51:38 -03:00
Rafael Moraes bc76032532 Update configuration options table in README 2025-10-27 15:13:05 -03:00
Rafael Moraes 42f782faa5 Update help text for --wvd-path option 2025-10-27 15:12:44 -03:00
Rafael Moraes 862a150c44 Bump version to 2.7.2 2025-10-27 15:08:57 -03:00
Rafael Moraes 4cfb626d00 Remove unknown params from config file 2025-10-27 15:06:24 -03:00
Rafael Moraes fdab6481ea Rename disc folder template options to file templates 2025-10-27 15:04:07 -03:00
Rafael Moraes 9eff34390b Bump version to 2.7.1 in pyproject.toml 2025-10-25 17:56:20 -03:00
Rafael Moraes f2c1961697 Bump version to 2.7.1 2025-10-25 17:37:02 -03:00
Rafael Moraes fff227522f Fix library urls 2025-10-25 17:36:10 -03:00
Rafael Moraes b7c813571e Reduce concurrency limit in safe_gather 2025-10-25 17:32:19 -03:00
Rafael Moraes 2c91982ae0 Update music video resolution option description 2025-10-23 23:08:21 -03:00
Rafael Moraes 04f847a9bf Add project repository URL to pyproject.toml 2025-10-23 17:38:53 -03:00
20 changed files with 693 additions and 535 deletions
+94 -70
View File
@@ -110,57 +110,56 @@ The file is created automatically on first run. Command-line arguments override
### Configuration Options
| Option | Description | Default |
| ------------------------------- | -------------------------------------- | ---------------------------------------------- |
| **General Options** | | |
| `--read-urls-as-txt`, `-r` | Read URLs from text files | `false` |
| `--config-path` | Config file path | `<home>/.gamdl/config.ini` |
| `--log-level` | Logging level | `INFO` |
| `--log-file` | Log file path | - |
| `--no-exceptions` | Don't print exceptions | `false` |
| `--no-config-file`, `-n` | Don't use a config file | `false` |
| **Apple Music Options** | | |
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
| `--language`, `-l` | Metadata language | `en-US` |
| **Output Options** | | |
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
| `--temp-path` | Temporary directory path | `.` |
| `--overwrite` | Overwrite existing files | `false` |
| `--save-cover`, `-s` | Save cover as separate file | `false` |
| `--save-playlist` | Save M3U8 playlist file | `false` |
| **Download Options** | | |
| `--download-mode` | Download mode | `ytdlp` |
| `--remux-mode` | Remux mode | `ffmpeg` |
| `--cover-format` | Cover format | `jpg` |
| `--cover-size` | Cover size in pixels | `1200` |
| `--truncate` | Max filename length | - |
| **Binary Paths** | | |
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
| `--mp4decrypt-path` | mp4decrypt executable path | `mp4decrypt` |
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
| `--mp4box-path` | MP4Box executable path | `MP4Box` |
| `--wvd-path` | .wvd file executable path | - |
| **Template Options** | | |
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
| `--single-disc-folder-template` | Single disc template | `{track:02d} {title}` |
| `--multi-disc-folder-template` | Multi disc template | `{disc}-{track:02d} {title}` |
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
| `--no-album-file-template` | No album file template | `{title}` |
| `--playlist-file-template` | Playlist template | `Playlists/{playlist_artist}/{playlist_title}` |
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
| `--exclude-tags` | Comma-separated tags to exclude | - |
| **Song Options** | | |
| `--codec-song` | Song codec | `aac-legacy` |
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
| **Music Video Options** | | |
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
| `--music-video-remux-format` | Music video remux format | `m4v` |
| `--music-video-resolution` | Max music video resolution (see below) | `1080p` |
| **Post Video Options** | | |
| `--uploaded-video-quality` | Post video quality | `best` |
| Option | Description | Default |
| ------------------------------- | ------------------------------- | ---------------------------------------------- |
| **General Options** | | |
| `--read-urls-as-txt`, `-r` | Read URLs from text files | `false` |
| `--config-path` | Config file path | `<home>/.gamdl/config.ini` |
| `--log-level` | Logging level | `INFO` |
| `--log-file` | Log file path | - |
| `--no-exceptions` | Don't print exceptions | `false` |
| `--no-config-file`, `-n` | Don't use a config file | `false` |
| **Apple Music Options** | | |
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
| `--language`, `-l` | Metadata language | `en-US` |
| **Output Options** | | |
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
| `--temp-path` | Temporary directory path | `.` |
| `--wvd-path` | .wvd file path | - |
| `--overwrite` | Overwrite existing files | `false` |
| `--save-cover`, `-s` | Save cover as separate file | `false` |
| `--save-playlist` | Save M3U8 playlist file | `false` |
| **Download Options** | | |
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
| `--mp4decrypt-path` | mp4decrypt executable path | `mp4decrypt` |
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
| `--mp4box-path` | MP4Box executable path | `MP4Box` |
| `--download-mode` | Download mode | `ytdlp` |
| `--remux-mode` | Remux mode | `ffmpeg` |
| `--cover-format` | Cover format | `jpg` |
| **Template Options** | | |
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
| `--single-disc-file-template` | Single disc file template | `{track:02d} {title}` |
| `--multi-disc-file-template` | Multi disc file template | `{disc}-{track:02d} {title}` |
| `--no-album-file-template` | No album file template | `{title}` |
| `--playlist-file-template` | Playlist file template | `Playlists/{playlist_artist}/{playlist_title}` |
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
| `--exclude-tags` | Comma-separated tags to exclude | - |
| `--cover-size` | Cover size in pixels | `1200` |
| `--truncate` | Max filename length | - |
| **Song Options** | | |
| `--song-codec` | Song codec | `aac-legacy` |
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
| **Music Video Options** | | |
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
| `--music-video-remux-format` | Music video remux format | `m4v` |
| `--music-video-resolution` | Max music video resolution | `1080p` |
| **Post Video Options** | | |
| `--uploaded-video-quality` | Post video quality | `best` |
### Template Variables
@@ -221,7 +220,7 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
- `aac-he-downmix` - AAC-HE 64kbps downmix
- `atmos` - Dolby Atmos 768kbps
- `ac3` - AC3 640kbps
- `alac` - ALAC up to 24-bit/192kHz
- `alac` - ALAC up to 24-bit/192kHz (unsupported)
- `ask` - Interactive experimental codec selection
### Synced Lyrics Format
@@ -256,7 +255,8 @@ Use Gamdl as a library in your Python projects:
```python
import asyncio
from gamdl.api import AppleMusicApi
from gamdl.api import AppleMusicApi, ItunesApi
from gamdl.downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
@@ -264,32 +264,56 @@ from gamdl.downloader import (
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
)
from gamdl.interface import (
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
)
async def main():
# Initialize API
api = AppleMusicApi.from_netscape_cookies(cookies_path="cookies.txt")
await api.setup()
# Initialize APIs
apple_music_api = AppleMusicApi.from_netscape_cookies(cookies_path="cookies.txt")
await apple_music_api.setup()
# Initialize downloaders
base_downloader = AppleMusicBaseDownloader(apple_music_api=api)
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
)
itunes_api.setup()
# Initialize interfaces
interface = AppleMusicInterface(apple_music_api, itunes_api)
song_interface = AppleMusicSongInterface(interface)
music_video_interface = AppleMusicMusicVideoInterface(interface)
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
# Initialize base downloader
base_downloader = AppleMusicBaseDownloader()
base_downloader.setup()
song_downloader = AppleMusicSongDownloader(base_downloader)
song_downloader.setup()
music_video_downloader = AppleMusicMusicVideoDownloader(base_downloader)
music_video_downloader.setup()
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(base_downloader)
uploaded_video_downloader.setup()
# Initialize specialized downloaders
song_downloader = AppleMusicSongDownloader(
base_downloader=base_downloader,
interface=song_interface,
)
music_video_downloader = AppleMusicMusicVideoDownloader(
base_downloader=base_downloader,
interface=music_video_interface,
)
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
base_downloader=base_downloader,
interface=uploaded_video_interface,
)
# Create main downloader
downloader = AppleMusicDownloader(
base_downloader,
song_downloader,
music_video_downloader,
uploaded_video_downloader,
interface=interface,
base_downloader=base_downloader,
song_downloader=song_downloader,
music_video_downloader=music_video_downloader,
uploaded_video_downloader=uploaded_video_downloader,
)
# Download a song
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.7"
__version__ = "2.7.4"
+2 -2
View File
@@ -85,8 +85,8 @@ class AppleMusicApi:
"l": self.language,
},
follow_redirects=True,
transport=httpx.AsyncHTTPTransport(retries=3),
timeout=30.0,
transport=httpx.AsyncHTTPTransport(retries=10),
timeout=60.0,
)
async def _setup_token(self) -> None:
+104 -51
View File
@@ -1,11 +1,13 @@
import asyncio
import inspect
import logging
from functools import wraps
from pathlib import Path
import click
from .. import __version__
from ..api import AppleMusicApi
from ..api import AppleMusicApi, ItunesApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
@@ -15,21 +17,26 @@ from ..downloader import (
CoverFormat,
DownloadItem,
DownloadMode,
MediaDownloadConfigurationError,
MediaFormatNotAvailableError,
MediaNotStreamableError,
GamdlFormatNotAvailableError,
GamdlNotStreamableError,
GamdlSyncedLyricsOnlyError,
RemuxFormatMusicVideo,
RemuxMode,
)
from ..interface import (
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
MusicVideoCodec,
MusicVideoResolution,
SongCodec,
SyncedLyricsFormat,
UploadedVideoQuality,
)
from .config_file import ConfigFile
from .constants import X_NOT_IN_PATH
from .utils import Csv, CustomLoggerFormatter, PathPrompt, load_config_file, make_sync
from .utils import Csv, CustomLoggerFormatter, prompt_path
logger = logging.getLogger(__name__)
@@ -42,6 +49,40 @@ uploaded_video_downloader_sig = inspect.signature(
)
def load_config_file(
ctx: click.Context,
param: click.Parameter,
no_config_file: bool,
) -> click.Context:
if no_config_file:
return ctx
config_file = ConfigFile(ctx.params["config_path"])
config_file.cleanup_unknown_params(ctx.command.params)
config_file.add_params_default_to_config(
ctx.command.params,
)
parsed_params = config_file.parse_params_from_config(
[
param
for param in ctx.command.params
if ctx.get_parameter_source(param.name)
!= click.core.ParameterSource.COMMANDLINE
]
)
ctx.params.update(parsed_params)
return ctx
def make_sync(func):
@wraps(func)
def wrapper(*args, **kwargs):
return asyncio.run(func(*args, **kwargs))
return wrapper
@click.command()
@click.help_option("-h", "--help")
@click.version_option(__version__, "-v", "--version")
@@ -85,7 +126,7 @@ uploaded_video_downloader_sig = inspect.signature(
@click.option(
"--cookies-path",
"-c",
type=PathPrompt(is_file=True),
type=click.Path(file_okay=True, dir_okay=False, readable=True, resolve_path=True),
default=api_sig.parameters["cookies_path"].default,
help="Cookies file path",
)
@@ -114,7 +155,7 @@ uploaded_video_downloader_sig = inspect.signature(
"--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=".wvd file executable path",
help=".wvd file path",
)
@click.option(
"--overwrite",
@@ -189,24 +230,24 @@ uploaded_video_downloader_sig = inspect.signature(
default=base_downloader_sig.parameters["compilation_folder_template"].default,
help="Compilation folder template",
)
@click.option(
"--single-disc-folder-template",
type=str,
default=base_downloader_sig.parameters["single_disc_folder_template"].default,
help="Single disc template",
)
@click.option(
"--multi-disc-folder-template",
type=str,
default=base_downloader_sig.parameters["multi_disc_folder_template"].default,
help="Multi disc template",
)
@click.option(
"--no-album-folder-template",
type=str,
default=base_downloader_sig.parameters["no_album_folder_template"].default,
help="No album folder template",
)
@click.option(
"--single-disc-file-template",
type=str,
default=base_downloader_sig.parameters["single_disc_file_template"].default,
help="Single disc file template",
)
@click.option(
"--multi-disc-file-template",
type=str,
default=base_downloader_sig.parameters["multi_disc_file_template"].default,
help="Multi disc file template",
)
@click.option(
"--no-album-file-template",
type=str,
@@ -217,7 +258,7 @@ uploaded_video_downloader_sig = inspect.signature(
"--playlist-file-template",
type=str,
default=base_downloader_sig.parameters["playlist_file_template"].default,
help="Playlist template",
help="Playlist file template",
)
@click.option(
"--date-tag-template",
@@ -245,7 +286,7 @@ uploaded_video_downloader_sig = inspect.signature(
)
# DownloaderSong specific options
@click.option(
"--codec-song",
"--song-codec",
type=SongCodec,
default=song_downloader_sig.parameters["codec"].default,
help="Song codec",
@@ -327,16 +368,16 @@ async def main(
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,
single_disc_file_template: str,
multi_disc_file_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,
song_codec: SongCodec,
synced_lyrics_format: SyncedLyricsFormat,
no_synced_lyrics: bool,
synced_lyrics_only: bool,
@@ -360,28 +401,43 @@ async def main(
file_handler.setFormatter(CustomLoggerFormatter(use_colors=False))
root_logger.addHandler(file_handler)
cookies_path = prompt_path(cookies_path)
logger.info(f"Starting Gamdl {__version__}")
api = AppleMusicApi.from_netscape_cookies(
apple_music_api = AppleMusicApi.from_netscape_cookies(
cookies_path=cookies_path,
language=language,
)
await api.setup()
await apple_music_api.setup()
if not api.account_info["meta"]["subscription"]["active"]:
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
)
itunes_api.setup()
if not apple_music_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"):
if apple_music_api.account_info["data"][0]["attributes"].get("restrictions"):
logger.warning(
"Your account has content restrictions enabled, some content may not be"
" downloadable"
)
interface = AppleMusicInterface(
apple_music_api,
itunes_api,
)
song_interface = AppleMusicSongInterface(interface)
music_video_interface = AppleMusicMusicVideoInterface(interface)
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
base_downloader = AppleMusicBaseDownloader(
apple_music_api=api,
output_path=output_path,
temp_path=temp_path,
wvd_path=wvd_path,
@@ -397,9 +453,9 @@ async def main(
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,
single_disc_file_template=single_disc_file_template,
multi_disc_file_template=multi_disc_file_template,
no_album_file_template=no_album_file_template,
playlist_file_template=playlist_file_template,
date_tag_template=date_tag_template,
@@ -408,35 +464,32 @@ async def main(
truncate=truncate,
)
base_downloader.setup()
song_downloader = AppleMusicSongDownloader(
base_downloader,
codec=codec_song,
base_downloader=base_downloader,
interface=song_interface,
codec=song_codec,
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,
base_downloader=base_downloader,
interface=music_video_interface,
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,
base_downloader=base_downloader,
interface=uploaded_video_interface,
quality=uploaded_video_quality,
)
uploaded_video_downloader.setup()
downloader = AppleMusicDownloader(
base_downloader,
song_downloader,
music_video_downloader,
uploaded_video_downloader,
interface=interface,
base_downloader=base_downloader,
song_downloader=song_downloader,
music_video_downloader=music_video_downloader,
uploaded_video_downloader=uploaded_video_downloader,
)
if not synced_lyrics_only:
@@ -452,7 +505,7 @@ async def main(
if (
not base_downloader.full_mp4decrypt_path
and codec_song
and song_codec
not in (
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
@@ -479,7 +532,7 @@ async def main(
)
downloader.skip_music_videos = True
if not codec_song.is_legacy():
if not song_codec.is_legacy():
logger.warning(
"You have chosen an experimental song codec. "
"They're not guaranteed to work due to API limitations."
@@ -552,9 +605,9 @@ async def main(
await downloader.download(download_item)
except (
FileExistsError,
MediaNotStreamableError,
MediaFormatNotAvailableError,
MediaDownloadConfigurationError,
GamdlNotStreamableError,
GamdlFormatNotAvailableError,
GamdlSyncedLyricsOnlyError,
) as e:
logger.warning(
download_queue_progress + f' Skipping "{media_title}": {e}'
+30 -19
View File
@@ -1,11 +1,12 @@
import configparser
import typing
from enum import Enum
from pathlib import Path
import click
from click.types import BoolParamType, FuncParamType
from .constants import EXCLUDED_CONFIG_FILE_PARAMS
from .utils import Csv
class ConfigFile:
@@ -35,34 +36,29 @@ class ConfigFile:
self.config.write(config_file)
def _serialize_param_default(self, param: click.Parameter) -> str:
if not isinstance(param.default, (list, tuple)):
param_default = [param.default]
else:
param_default = param.default
if not param_default:
return ""
first = param_default[0]
if isinstance(first, Enum):
return ",".join(str(item.value) for item in param_default)
if isinstance(first, bool):
return ",".join(str(item).lower() for item in param_default)
if first is None:
if param.default is None:
return "null"
return ",".join(str(item) for item in param_default)
if isinstance(param.type, Csv):
return ",".join(item.value for item in param.default)
if isinstance(param.type, BoolParamType):
return str(param.default).lower()
if isinstance(param.type, FuncParamType):
return param.default.value
return str(param.default)
def _add_param_default_to_config(
self,
param: click.Parameter,
) -> bool:
if self.config[self.section_name].get(param.name):
if self.config.has_option(self.section_name, param.name):
return False
value = self._serialize_param_default(param)
self.config[self.section_name][param.name] = value
self.config.set(self.section_name, param.name, value)
return True
@@ -92,6 +88,21 @@ class ConfigFile:
if has_changes:
self._write_config_file()
def cleanup_unknown_params(
self,
params: list[click.Parameter],
) -> None:
param_names = {param.name for param in params}
has_changes = False
for key in list(self.config[self.section_name].keys()):
if key not in param_names:
self.config.remove_option(self.section_name, key)
has_changes = True
if has_changes:
self._write_config_file()
def parse_params_from_config(
self,
params: list[click.Parameter],
+30 -76
View File
@@ -1,29 +1,25 @@
import asyncio
import logging
import typing
from functools import wraps
from enum import Enum
from pathlib import Path
import click
from .config_file import ConfigFile
class Csv(click.ParamType):
name = "csv"
def __init__(
self,
subtype: typing.Any,
subtype: Enum,
) -> None:
self.subtype = subtype
def convert(
self,
value: str | typing.Any,
value: str,
param: click.Parameter,
ctx: click.Context,
) -> list[typing.Any]:
) -> list[Enum]:
if not isinstance(value, str):
return value
@@ -42,46 +38,6 @@ class Csv(click.ParamType):
return result
class PathPrompt(click.ParamType):
name = "path"
def __init__(self, is_file: bool = False) -> None:
self.is_file = is_file
def convert(
self,
value: str | typing.Any,
param: click.Parameter,
ctx: click.Context,
) -> str:
if not isinstance(value, str):
return value
path_validator = click.Path(
exists=True,
file_okay=self.is_file,
dir_okay=not self.is_file,
)
path_type = "file" if self.is_file else "directory"
while True:
try:
result = path_validator.convert(value, None, None)
break
except click.BadParameter as e:
value = click.prompt(
(
f'{path_type.capitalize()} "{Path(value).absolute()}" does not exist. '
f"Create the {path_type} at the specified path, "
f"type a new path or drag and drop the {path_type} here. "
"Then, press enter to continue"
),
default=value,
show_default=False,
)
value = value.strip('"')
return result
class CustomLoggerFormatter(logging.Formatter):
base_format = "[%(levelname)-8s %(asctime)s]"
format_colors = {
@@ -109,34 +65,32 @@ class CustomLoggerFormatter(logging.Formatter):
).format(record)
def load_config_file(
ctx: click.Context,
param: click.Parameter,
no_config_file: bool,
) -> click.Context:
if no_config_file:
return ctx
config_file = ConfigFile(ctx.params["config_path"])
config_file.add_params_default_to_config(
ctx.command.params,
def prompt_path(
input_path: str,
is_dir: bool = False,
) -> str:
path_validator = click.Path(
exists=True,
file_okay=not is_dir,
dir_okay=is_dir,
)
parsed_params = config_file.parse_params_from_config(
[
param
for param in ctx.command.params
if ctx.get_parameter_source(param.name)
!= click.core.ParameterSource.COMMANDLINE
]
)
ctx.params.update(parsed_params)
path_type = "directory" if is_dir else "file"
return ctx
while True:
try:
result_path = path_validator.convert(input_path, None, None)
break
except click.BadParameter as e:
input_path = click.prompt(
(
f'{path_type.capitalize()} "{Path(input_path).absolute()}" does not exist. '
f"Create the {path_type} at the specified path, "
f"type a new path or drag and drop the {path_type} here. "
"Then, press enter to continue"
),
default=input_path,
show_default=False,
)
input_path = input_path.strip('"')
def make_sync(func):
@wraps(func)
def wrapper(*args, **kwargs):
return asyncio.run(func(*args, **kwargs))
return wrapper
return result_path
+119 -59
View File
@@ -1,9 +1,11 @@
import asyncio
import typing
from pathlib import Path
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from ..interface import AppleMusicInterface
from ..utils import safe_gather
from .constants import (
ALBUM_MEDIA_TYPE,
@@ -18,10 +20,12 @@ from .downloader_base import AppleMusicBaseDownloader
from .downloader_music_video import AppleMusicMusicVideoDownloader
from .downloader_song import AppleMusicSongDownloader
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
from .enums import DownloadMode, RemuxMode
from .exceptions import (
MediaFormatNotAvailableError,
MediaNotStreamableError,
MediaDownloadConfigurationError,
GamdlExecutableNotFoundError,
GamdlFormatNotAvailableError,
GamdlNotStreamableError,
GamdlSyncedLyricsOnlyError,
)
from .types import DownloadItem, UrlInfo
@@ -29,22 +33,50 @@ from .types import DownloadItem, UrlInfo
class AppleMusicDownloader:
def __init__(
self,
interface: AppleMusicInterface,
base_downloader: AppleMusicBaseDownloader,
song_downloader: AppleMusicSongDownloader,
music_video_downloader: AppleMusicMusicVideoDownloader,
uploaded_video_downloader: AppleMusicUploadedVideoDownloader,
skip_music_videos: bool = False,
skip_processing: bool = False,
flat_filter: typing.Callable = None,
):
self.interface = interface
self.base_downloader = base_downloader
self.song_downloader = song_downloader
self.music_video_downloader = music_video_downloader
self.uploaded_video_downloader = uploaded_video_downloader
self.skip_music_videos = skip_music_videos
self.skip_processing = skip_processing
self.flat_filter = flat_filter
async def get_single_download_item(
self,
media_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
if self.flat_filter:
flat_filter_result = self.flat_filter(media_metadata)
if asyncio.iscoroutine(flat_filter_result):
flat_filter_result = await flat_filter_result
if flat_filter_result:
return DownloadItem(
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
flat_filter_result=flat_filter_result,
)
return await self.get_single_download_item_no_filter(
media_metadata,
playlist_metadata,
)
async def get_single_download_item_no_filter(
self,
media_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
download_item = None
@@ -70,15 +102,12 @@ class AppleMusicDownloader:
async def get_collection_download_items(
self,
collection_metadata: dict,
) -> list[DownloadItem | Exception]:
collection_metadata["relationships"]["tracks"]["data"].extend(
[
extended_data
async for extended_data in self.base_downloader.apple_music_api.extend_api_data(
collection_metadata["relationships"]["tracks"],
)
]
)
) -> list[DownloadItem]:
tracks_metadata = collection_metadata["relationships"]["tracks"]["data"]
async for extended_data in self.interface.apple_music_api.extend_api_data(
collection_metadata["relationships"]["tracks"],
):
tracks_metadata.extend(extended_data["data"])
tasks = [
asyncio.create_task(
@@ -91,7 +120,7 @@ class AppleMusicDownloader:
),
)
)
for media_metadata in collection_metadata["relationships"]["tracks"]["data"]
for media_metadata in tracks_metadata
]
download_items = await safe_gather(*tasks)
@@ -100,12 +129,12 @@ class AppleMusicDownloader:
async def get_artist_download_items(
self,
artist_metadata: dict,
) -> list[DownloadItem | Exception]:
) -> list[DownloadItem]:
for relationship in artist_metadata["relationships"].keys():
artist_metadata["relationships"][relationship]["data"].extend(
[
extended_data
async for extended_data in self.base_downloader.apple_music_api.extend_api_data(
async for extended_data in self.interface.apple_music_api.extend_api_data(
artist_metadata["relationships"][relationship],
)
]
@@ -141,7 +170,7 @@ class AppleMusicDownloader:
async def get_artist_albums_download_items(
self,
albums_metadata: list[dict],
) -> list[DownloadItem | Exception]:
) -> list[DownloadItem]:
choices = [
Choice(
name=" | ".join(
@@ -166,7 +195,7 @@ class AppleMusicDownloader:
album_tasks = [
asyncio.create_task(
self.base_downloader.apple_music_api.get_album(album_metadata["id"])
self.interface.apple_music_api.get_album(album_metadata["id"])
)
for album_metadata in selected
]
@@ -188,7 +217,7 @@ class AppleMusicDownloader:
async def get_artist_music_videos_download_items(
self,
music_videos_metadata: list[dict],
) -> list[DownloadItem | Exception]:
) -> list[DownloadItem]:
choices = [
Choice(
name=" | ".join(
@@ -238,9 +267,9 @@ class AppleMusicDownloader:
async def get_download_queue(
self,
url_info: UrlInfo,
) -> list[DownloadItem | Exception] | None:
) -> list[DownloadItem] | None:
return await self._get_download_queue(
"song" if url_info.sub_id else url_info.type,
"song" if url_info.sub_id else url_info.type or url_info.library_type,
url_info.sub_id or url_info.id or url_info.library_id,
url_info.library_id is not None,
)
@@ -250,11 +279,11 @@ class AppleMusicDownloader:
url_type: str,
id: str,
is_library: bool,
) -> list[DownloadItem | Exception] | None:
) -> list[DownloadItem] | None:
download_items = []
if url_type in ARTIST_MEDIA_TYPE:
artist_response = await self.base_downloader.apple_music_api.get_artist(
artist_response = await self.interface.apple_music_api.get_artist(
id,
)
@@ -266,7 +295,7 @@ class AppleMusicDownloader:
)
if url_type in SONG_MEDIA_TYPE:
song_respose = await self.base_downloader.apple_music_api.get_song(id)
song_respose = await self.interface.apple_music_api.get_song(id)
if song_respose is None:
return None
@@ -277,13 +306,11 @@ class AppleMusicDownloader:
if url_type in ALBUM_MEDIA_TYPE:
if is_library:
album_response = (
await self.base_downloader.apple_music_api.get_library_album(id)
)
else:
album_response = await self.base_downloader.apple_music_api.get_album(
album_response = await self.interface.apple_music_api.get_library_album(
id
)
else:
album_response = await self.interface.apple_music_api.get_album(id)
if album_response is None:
return None
@@ -295,11 +322,11 @@ class AppleMusicDownloader:
if url_type in PLAYLIST_MEDIA_TYPE:
if is_library:
playlist_response = (
await self.base_downloader.apple_music_api.get_library_playlist(id)
await self.interface.apple_music_api.get_library_playlist(id)
)
else:
playlist_response = (
await self.base_downloader.apple_music_api.get_playlist(id)
playlist_response = await self.interface.apple_music_api.get_playlist(
id
)
if playlist_response is None:
@@ -310,8 +337,8 @@ class AppleMusicDownloader:
)
if url_type in MUSIC_VIDEO_MEDIA_TYPE:
music_video_response = (
await self.base_downloader.apple_music_api.get_music_video(id)
music_video_response = await self.interface.apple_music_api.get_music_video(
id
)
if music_video_response is None:
@@ -322,9 +349,7 @@ class AppleMusicDownloader:
)
if url_type in UPLOADED_VIDEO_MEDIA_TYPE:
uploaded_video = (
await self.base_downloader.apple_music_api.get_uploaded_video(id)
)
uploaded_video = await self.interface.apple_music_api.get_uploaded_video(id)
if uploaded_video is None:
return None
@@ -335,44 +360,46 @@ class AppleMusicDownloader:
return download_items
async def download(self, download_item: DownloadItem | Exception) -> None:
async def download(
self,
download_item: DownloadItem,
) -> DownloadItem:
try:
if isinstance(download_item, Exception):
raise download_item
if download_item.flat_filter_result:
download_item = await self.get_single_download_item_no_filter(
download_item.media_metadata,
download_item.playlist_metadata,
)
await self._initial_processing(download_item)
await self._download(download_item)
await self._final_processing(download_item)
return download_item
finally:
if isinstance(download_item, DownloadItem):
if isinstance(download_item, DownloadItem) and not self.skip_processing:
self.base_downloader.cleanup_temp(download_item.random_uuid)
async def _download(
self,
download_item: DownloadItem,
) -> None:
if download_item.error:
raise download_item.error
if (
self.song_downloader.synced_lyrics_only
and download_item.media_metadata["type"] not in SONG_MEDIA_TYPE
) or (
self.skip_music_videos
and download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
):
raise MediaDownloadConfigurationError(download_item.media_metadata["id"])
raise GamdlSyncedLyricsOnlyError()
if self.song_downloader.synced_lyrics_only:
return
if download_item.media_metadata["type"] in {
*SONG_MEDIA_TYPE,
*MUSIC_VIDEO_MEDIA_TYPE,
} and (
not download_item.stream_info
or not download_item.stream_info.audio_track.widevine_pssh
if not self.base_downloader.is_media_streamable(
download_item.media_metadata,
):
raise MediaFormatNotAvailableError(
download_item.media_metadata["id"],
)
raise GamdlNotStreamableError()
if (
Path(download_item.final_path).exists()
@@ -382,12 +409,39 @@ class AppleMusicDownloader:
f'Media file already exists at "{download_item.final_path}"'
)
if not self.base_downloader.is_media_streamable(
download_item.media_metadata,
):
raise MediaNotStreamableError(
download_item.media_metadata["id"],
)
if download_item.media_metadata["type"] in {
*SONG_MEDIA_TYPE,
*MUSIC_VIDEO_MEDIA_TYPE,
}:
if (
self.base_downloader.remux_mode == RemuxMode.FFMPEG
and not self.base_downloader.full_ffmpeg_path
):
raise GamdlExecutableNotFoundError("ffmpeg")
if (
self.base_downloader.remux_mode == RemuxMode.MP4BOX
and not self.base_downloader.full_mp4box_path
):
raise GamdlExecutableNotFoundError("MP4Box")
if (
download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
or self.base_downloader.remux_mode == RemuxMode.MP4BOX
) and not self.base_downloader.full_mp4decrypt_path:
raise GamdlExecutableNotFoundError("mp4decrypt")
if (
self.base_downloader.download_mode == DownloadMode.NM3U8DLRE
and not self.base_downloader.full_nm3u8dlre_path
):
raise GamdlExecutableNotFoundError("N_m3u8DL-RE")
if (
not download_item.stream_info
or not download_item.stream_info.audio_track.widevine_pssh
):
raise GamdlFormatNotAvailableError()
if download_item.media_metadata["type"] in SONG_MEDIA_TYPE:
await self.song_downloader.download(download_item)
@@ -402,6 +456,9 @@ class AppleMusicDownloader:
self,
download_item: DownloadItem,
) -> None:
if self.skip_processing:
return
if download_item.cover_path and self.base_downloader.save_cover:
cover_url = self.base_downloader.get_cover_url(
download_item.cover_url_template,
@@ -441,6 +498,9 @@ class AppleMusicDownloader:
self,
download_item: DownloadItem,
) -> None:
if self.skip_processing:
return
if download_item.staged_path and Path(download_item.staged_path).exists():
self.base_downloader.move_to_final_path(
download_item.staged_path,
+37 -25
View File
@@ -12,9 +12,6 @@ from PIL import Image
from pywidevine import Cdm, Device
from yt_dlp import YoutubeDL
from ..api.apple_music_api import AppleMusicApi
from ..api.itunes_api import ItunesApi
from ..interface.interface import AppleMusicInterface
from ..interface.types import MediaTags, PlaylistTags
from ..utils import async_subprocess, raise_for_status
from .constants import (
@@ -30,7 +27,6 @@ from .hardcoded_wvd import HARDCODED_WVD
class AppleMusicBaseDownloader:
def __init__(
self,
apple_music_api: AppleMusicApi,
output_path: str = "./Apple Music",
temp_path: str = ".",
wvd_path: str = None,
@@ -46,9 +42,9 @@ class AppleMusicBaseDownloader:
cover_format: CoverFormat = CoverFormat.JPG,
album_folder_template: str = "{album_artist}/{album}",
compilation_folder_template: str = "Compilations/{album}",
single_disc_folder_template: str = "{track:02d} {title}",
multi_disc_folder_template: str = "{disc}-{track:02d} {title}",
no_album_folder_template: str = "{artist}/Unknown Album",
single_disc_file_template: str = "{track:02d} {title}",
multi_disc_file_template: str = "{disc}-{track:02d} {title}",
no_album_file_template: str = "{title}",
playlist_file_template: str = "Playlists/{playlist_artist}/{playlist_title}",
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
@@ -56,9 +52,7 @@ class AppleMusicBaseDownloader:
cover_size: int = 1200,
truncate: int = None,
silent: bool = False,
skip_processing: bool = False,
):
self.apple_music_api = apple_music_api
self.output_path = output_path
self.temp_path = temp_path
self.wvd_path = wvd_path
@@ -74,9 +68,9 @@ class AppleMusicBaseDownloader:
self.cover_format = cover_format
self.album_folder_template = album_folder_template
self.compilation_folder_template = compilation_folder_template
self.single_disc_folder_template = single_disc_folder_template
self.multi_disc_folder_template = multi_disc_folder_template
self.no_album_folder_template = no_album_folder_template
self.single_disc_file_template = single_disc_file_template
self.multi_disc_file_template = multi_disc_file_template
self.no_album_file_template = no_album_file_template
self.playlist_file_template = playlist_file_template
self.date_tag_template = date_tag_template
@@ -84,12 +78,10 @@ class AppleMusicBaseDownloader:
self.cover_size = cover_size
self.truncate = truncate
self.silent = silent
self.skip_processing = skip_processing
def setup(self):
self._setup_binary_paths()
self._setup_cdm()
self._setup_interface()
def _setup_binary_paths(self):
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
@@ -104,14 +96,6 @@ class AppleMusicBaseDownloader:
self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
self.cdm.MAX_NUM_OF_SESSIONS = float("inf")
def _setup_interface(self):
self.itunes_api = ItunesApi(
self.apple_music_api.storefront,
self.apple_music_api.language,
)
self.itunes_api.setup()
self.interface = AppleMusicInterface(self.apple_music_api, self.itunes_api)
def get_random_uuid(self) -> str:
return uuid.uuid4().hex[:8]
@@ -207,9 +191,9 @@ class AppleMusicBaseDownloader:
else self.album_folder_template.split("/")
)
template_file = (
self.multi_disc_folder_template.split("/")
self.multi_disc_file_template.split("/")
if tags.disc_total > 1
else self.single_disc_folder_template.split("/")
else self.single_disc_file_template.split("/")
)
else:
template_folder = self.no_album_folder_template.split("/")
@@ -343,15 +327,43 @@ class AppleMusicBaseDownloader:
}
)
mp4_tags = filtered_tags.as_mp4_tags(self.date_tag_template)
cover_url = self.get_cover_url(cover_url_template)
cover_bytes = await self.get_cover_bytes(cover_url)
skip_tagging = "all" in exclude_tags
await asyncio.to_thread(
self.apply_mp4_tags,
media_path,
mp4_tags,
cover_bytes,
skip_tagging,
)
def apply_mp4_tags(
self,
media_path: Path,
tags: dict,
cover_bytes: bytes | None,
skip_tagging: bool,
):
mp4 = MP4(media_path)
mp4.clear()
if not skip_tagging:
if "cover" not in exclude_tags and self.cover_format != CoverFormat.RAW:
await self._apply_cover(mp4, cover_url_template)
mp4.update(mp4_tags)
if cover_bytes is not None:
mp4["covr"] = [
MP4Cover(
data=cover_bytes,
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
mp4.update(tags)
mp4.save()
+52 -44
View File
@@ -9,10 +9,11 @@ from .enums import RemuxFormatMusicVideo, RemuxMode
from .types import DownloadItem
class AppleMusicMusicVideoDownloader:
class AppleMusicMusicVideoDownloader(AppleMusicBaseDownloader):
def __init__(
self,
downloader: AppleMusicBaseDownloader,
base_downloader: AppleMusicBaseDownloader,
interface: AppleMusicMusicVideoInterface,
codec_priority: list[MusicVideoCodec] = [
MusicVideoCodec.H264,
MusicVideoCodec.H265,
@@ -20,19 +21,12 @@ class AppleMusicMusicVideoDownloader:
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
):
self.downloader = downloader
self.__dict__.update(base_downloader.__dict__)
self.interface = interface
self.codec_priority = codec_priority
self.remux_format = remux_format
self.resolution = resolution
def setup(self):
self._setup_interface()
def _setup_interface(self):
self.music_video_interface = AppleMusicMusicVideoInterface(
self.downloader.interface,
)
async def remux_mp4box(
self,
input_path_video: str,
@@ -40,7 +34,7 @@ class AppleMusicMusicVideoDownloader:
output_path: str,
):
await async_subprocess(
self.downloader.full_mp4box_path,
self.full_mp4box_path,
"-quiet",
"-add",
input_path_audio,
@@ -51,7 +45,7 @@ class AppleMusicMusicVideoDownloader:
"-keep-utc",
"-new",
output_path,
silent=self.downloader.silent,
silent=self.silent,
)
async def remux_ffmpeg(
@@ -70,7 +64,7 @@ class AppleMusicMusicVideoDownloader:
key = []
await async_subprocess(
self.downloader.full_ffmpeg_path,
self.full_ffmpeg_path,
"-loglevel",
"error",
"-y",
@@ -86,7 +80,7 @@ class AppleMusicMusicVideoDownloader:
"-movflags",
"+faststart",
output_path,
silent=self.downloader.silent,
silent=self.silent,
)
async def decrypt_mp4decrypt(
@@ -96,12 +90,12 @@ class AppleMusicMusicVideoDownloader:
decryption_key: str,
):
await async_subprocess(
self.downloader.full_mp4decrypt_path,
self.full_mp4decrypt_path,
"--key",
f"1:{decryption_key}",
input_path,
output_path,
silent=self.downloader.silent,
silent=self.silent,
)
async def stage(
@@ -124,7 +118,7 @@ class AppleMusicMusicVideoDownloader:
decryption_key.audio_track.key,
)
if self.downloader.remux_mode == RemuxMode.MP4BOX:
if self.remux_mode == RemuxMode.MP4BOX:
await self.remux_mp4box(
decrypted_path_video,
decrypted_path_audio,
@@ -148,50 +142,64 @@ class AppleMusicMusicVideoDownloader:
self,
music_video_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
try:
return await self._get_download_item(
music_video_metadata,
playlist_metadata,
)
except Exception as e:
return DownloadItem(
media_metadata=music_video_metadata,
playlist_metadata=playlist_metadata,
error=e,
)
async def _get_download_item(
self,
music_video_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
download_item = DownloadItem()
download_item.media_metadata = music_video_metadata
download_item.playlist_metadata = playlist_metadata
music_video_id = self.downloader.interface.get_media_id_of_library_media(
music_video_id = self.interface.get_media_id_of_library_media(
music_video_metadata,
)
itunes_page_metadata = (
await self.music_video_interface.get_itunes_page_metadata(
music_video_metadata,
)
itunes_page_metadata = await self.interface.get_itunes_page_metadata(
music_video_metadata,
)
download_item.media_tags = await self.music_video_interface.get_tags(
download_item.media_tags = await self.interface.get_tags(
music_video_metadata,
itunes_page_metadata,
)
if playlist_metadata:
download_item.playlist_tags = self.downloader.get_playlist_tags(
download_item.playlist_tags = self.get_playlist_tags(
playlist_metadata,
music_video_metadata,
)
download_item.playlist_file_path = self.downloader.get_playlist_file_path(
download_item.playlist_file_path = self.get_playlist_file_path(
download_item.playlist_tags,
)
stream_info = await self.music_video_interface.get_stream_info(
download_item.stream_info = await self.interface.get_stream_info(
music_video_metadata,
itunes_page_metadata,
self.codec_priority,
self.resolution,
)
download_item.stream_info = stream_info
decryption_key = await self.music_video_interface.get_decryption_key(
stream_info,
self.downloader.cdm,
download_item.decryption_key = await self.interface.get_decryption_key(
download_item.stream_info,
self.cdm,
)
download_item.decryption_key = decryption_key
download_item.random_uuid = self.downloader.get_random_uuid()
download_item.staged_path = self.downloader.get_temp_path(
download_item.random_uuid = self.get_random_uuid()
download_item.staged_path = self.get_temp_path(
music_video_id,
download_item.random_uuid,
"staged",
@@ -204,16 +212,16 @@ class AppleMusicMusicVideoDownloader:
)
),
)
download_item.final_path = self.downloader.get_final_path(
download_item.final_path = self.get_final_path(
download_item.media_tags,
Path(download_item.staged_path).suffix,
playlist_metadata,
)
download_item.cover_url_template = self.downloader.get_cover_url_template(
download_item.cover_url_template = self.get_cover_url_template(
music_video_metadata,
)
cover_file_extension = await self.downloader.get_cover_file_extension(
cover_file_extension = await self.get_cover_file_extension(
download_item.cover_url_template,
)
if cover_file_extension:
@@ -228,35 +236,35 @@ class AppleMusicMusicVideoDownloader:
self,
download_item: DownloadItem,
) -> None:
encrypted_path_video = self.downloader.get_temp_path(
encrypted_path_video = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"encrypted_video",
".mp4",
)
encrypted_path_audio = self.downloader.get_temp_path(
encrypted_path_audio = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"encrypted_audio",
".m4a",
)
await self.downloader.download_stream(
await self.download_stream(
download_item.stream_info.video_track.stream_url,
encrypted_path_video,
)
await self.downloader.download_stream(
await self.download_stream(
download_item.stream_info.audio_track.stream_url,
encrypted_path_audio,
)
decrypted_path_video = self.downloader.get_temp_path(
decrypted_path_video = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"decrypted_video",
".mp4",
)
decrypted_path_audio = self.downloader.get_temp_path(
decrypted_path_audio = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"decrypted_audio",
@@ -272,7 +280,7 @@ class AppleMusicMusicVideoDownloader:
download_item.decryption_key,
)
await self.downloader.apply_tags(
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
download_item.cover_url_template,
+56 -47
View File
@@ -10,59 +10,73 @@ from .enums import RemuxMode
from .types import DownloadItem
class AppleMusicSongDownloader:
class AppleMusicSongDownloader(AppleMusicBaseDownloader):
def __init__(
self,
downloader: AppleMusicBaseDownloader,
base_downloader: AppleMusicBaseDownloader,
interface: AppleMusicSongInterface,
codec: SongCodec = SongCodec.AAC_LEGACY,
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
no_synced_lyrics: bool = False,
synced_lyrics_only: bool = False,
):
self.downloader = downloader
self.__dict__.update(base_downloader.__dict__)
self.interface = interface
self.codec = codec
self.synced_lyrics_format = synced_lyrics_format
self.no_synced_lyrics = no_synced_lyrics
self.synced_lyrics_only = synced_lyrics_only
def setup(self):
self._setup_interface()
def _setup_interface(self):
self.song_interface = AppleMusicSongInterface(self.downloader.interface)
async def get_download_item(
self,
song_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
try:
return await self._get_download_item(
song_metadata,
playlist_metadata,
)
except Exception as e:
return DownloadItem(
media_metadata=song_metadata,
playlist_metadata=playlist_metadata,
error=e,
)
async def _get_download_item(
self,
song_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
download_item = DownloadItem()
download_item.media_metadata = song_metadata
download_item.playlist_metadata = playlist_metadata
song_id = self.downloader.interface.get_media_id_of_library_media(song_metadata)
song_id = self.interface.get_media_id_of_library_media(song_metadata)
download_item.lyrics = await self.song_interface.get_lyrics(
download_item.lyrics = await self.interface.get_lyrics(
song_metadata,
self.synced_lyrics_format,
)
webplayback = await self.downloader.apple_music_api.get_webplayback(song_id)
download_item.media_tags = self.song_interface.get_tags(
webplayback = await self.interface.apple_music_api.get_webplayback(song_id)
download_item.media_tags = self.interface.get_tags(
webplayback,
download_item.lyrics.unsynced if download_item.lyrics else None,
)
if playlist_metadata:
download_item.playlist_tags = self.downloader.get_playlist_tags(
download_item.playlist_tags = self.get_playlist_tags(
playlist_metadata,
song_metadata,
)
download_item.playlist_file_path = self.downloader.get_playlist_file_path(
download_item.playlist_file_path = self.get_playlist_file_path(
download_item.playlist_tags,
)
download_item.final_path = self.downloader.get_final_path(
download_item.final_path = self.get_final_path(
download_item.media_tags,
".m4a",
download_item.playlist_tags,
@@ -75,48 +89,43 @@ class AppleMusicSongDownloader:
return download_item
if self.codec.is_legacy():
download_item.stream_info = (
await self.song_interface.get_stream_info_legacy(
webplayback,
self.codec,
)
download_item.stream_info = await self.interface.get_stream_info_legacy(
webplayback,
self.codec,
)
download_item.decryption_key = (
await self.song_interface.get_decryption_key_legacy(
await self.interface.get_decryption_key_legacy(
download_item.stream_info,
self.downloader.cdm,
self.cdm,
)
)
else:
download_item.stream_info = await self.song_interface.get_stream_info(
download_item.stream_info = await self.interface.get_stream_info(
song_metadata,
self.codec,
)
if (
download_item.stream_info
and download_item.stream_info.audio_track.widevine_pssh
and self.codec != SongCodec.ALAC
):
download_item.decryption_key = (
await self.song_interface.get_decryption_key(
download_item.stream_info,
self.downloader.cdm,
)
download_item.decryption_key = await self.interface.get_decryption_key(
download_item.stream_info,
self.cdm,
)
else:
download_item.decryption_key = None
download_item.cover_url_template = self.downloader.get_cover_url_template(
song_metadata
)
download_item.cover_url_template = self.get_cover_url_template(song_metadata)
download_item.random_uuid = self.downloader.get_random_uuid()
download_item.staged_path = self.downloader.get_temp_path(
download_item.random_uuid = self.get_random_uuid()
download_item.staged_path = self.get_temp_path(
song_id,
download_item.random_uuid,
"staged",
"." + download_item.stream_info.file_format.value,
)
cover_file_extension = await self.downloader.get_cover_file_extension(
cover_file_extension = await self.get_cover_file_extension(
download_item.cover_url_template,
)
if cover_file_extension:
@@ -143,7 +152,7 @@ class AppleMusicSongDownloader:
async def remux_mp4box(self, input_path: str, output_path: str):
await async_subprocess(
self.downloader.full_mp4box_path,
self.full_mp4box_path,
"-quiet",
"-add",
input_path,
@@ -152,7 +161,7 @@ class AppleMusicSongDownloader:
"-keep-utc",
"-new",
output_path,
silent=self.downloader.silent,
silent=self.silent,
)
async def remux_ffmpeg(
@@ -170,7 +179,7 @@ class AppleMusicSongDownloader:
key = []
await async_subprocess(
self.downloader.full_ffmpeg_path,
self.full_ffmpeg_path,
"-loglevel",
"error",
"-y",
@@ -182,7 +191,7 @@ class AppleMusicSongDownloader:
"-movflags",
"+faststart",
output_path,
silent=self.downloader.silent,
silent=self.silent,
)
async def decrypt_mp4decrypt(
@@ -207,11 +216,11 @@ class AppleMusicSongDownloader:
]
await async_subprocess(
self.downloader.full_mp4decrypt_path,
self.full_mp4decrypt_path,
*keys,
input_path,
output_path,
silent=self.downloader.silent,
silent=self.silent,
)
async def stage(
@@ -222,7 +231,7 @@ class AppleMusicSongDownloader:
decryption_key: DecryptionKeyAv,
codec: SongCodec,
):
if codec.is_legacy() and self.downloader.remux_mode == RemuxMode.FFMPEG:
if codec.is_legacy() and self.remux_mode == RemuxMode.FFMPEG:
await self.remux_ffmpeg(
encrypted_path,
staged_path,
@@ -235,7 +244,7 @@ class AppleMusicSongDownloader:
decryption_key.audio_track.key,
codec.is_legacy(),
)
if self.downloader.remux_mode == RemuxMode.FFMPEG:
if self.remux_mode == RemuxMode.FFMPEG:
await self.remux_ffmpeg(
decrypted_path,
staged_path,
@@ -271,18 +280,18 @@ class AppleMusicSongDownloader:
if self.synced_lyrics_only:
return
encrypted_path = self.downloader.get_temp_path(
encrypted_path = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"encrypted",
".m4a",
)
await self.downloader.download_stream(
await self.download_stream(
download_item.stream_info.audio_track.stream_url,
encrypted_path,
)
decrypted_path = self.downloader.get_temp_path(
decrypted_path = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"decrypted",
@@ -296,7 +305,7 @@ class AppleMusicSongDownloader:
self.codec,
)
await self.downloader.apply_tags(
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
download_item.cover_url_template,
+28 -20
View File
@@ -6,60 +6,68 @@ from .downloader_base import AppleMusicBaseDownloader
from .types import DownloadItem
class AppleMusicUploadedVideoDownloader:
class AppleMusicUploadedVideoDownloader(AppleMusicBaseDownloader):
def __init__(
self,
downloader: AppleMusicBaseDownloader,
base_downloader: AppleMusicBaseDownloader,
interface: AppleMusicUploadedVideoInterface,
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
):
self.downloader = downloader
self.__dict__.update(base_downloader.__dict__)
self.interface = interface
self.quality = quality
def setup(self):
self._setup_interface()
def _setup_interface(self):
self.uploaded_video_interface = AppleMusicUploadedVideoInterface(
self.downloader.interface,
)
def get_cover_path(self, final_path: str, file_extension: str) -> str:
return str(Path(final_path).with_suffix(file_extension))
async def get_download_item(
self,
uploaded_video_metadata: dict,
) -> DownloadItem:
try:
return await self._get_download_item(
uploaded_video_metadata,
)
except Exception as e:
return DownloadItem(
media_metadata=uploaded_video_metadata,
error=e,
)
async def _get_download_item(
self,
uploaded_video_metadata: dict,
) -> DownloadItem:
download_item = DownloadItem()
download_item.media_metadata = uploaded_video_metadata
download_item.media_tags = self.uploaded_video_interface.get_tags(
download_item.media_tags = self.interface.get_tags(
uploaded_video_metadata,
)
download_item.stream_info = await self.uploaded_video_interface.get_stream_info(
download_item.stream_info = await self.interface.get_stream_info(
uploaded_video_metadata,
self.quality,
)
download_item.random_uuid = self.downloader.get_random_uuid()
download_item.staged_path = self.downloader.get_temp_path(
download_item.random_uuid = self.get_random_uuid()
download_item.staged_path = self.get_temp_path(
uploaded_video_metadata["id"],
download_item.random_uuid,
"staged",
"." + download_item.stream_info.file_format.value,
)
download_item.final_path = self.downloader.get_final_path(
download_item.final_path = self.get_final_path(
download_item.media_tags,
Path(download_item.staged_path).suffix,
None,
)
download_item.cover_url_template = self.downloader.get_cover_url_template(
download_item.cover_url_template = self.get_cover_url_template(
uploaded_video_metadata,
)
cover_file_extension = await self.downloader.get_cover_file_extension(
cover_file_extension = await self.get_cover_file_extension(
download_item.cover_url_template,
)
if cover_file_extension:
@@ -74,11 +82,11 @@ class AppleMusicUploadedVideoDownloader:
self,
download_item: DownloadItem,
) -> None:
await self.downloader.download_ytdlp(
await self.download_ytdlp(
download_item.stream_info.video_track.stream_url,
download_item.staged_path,
)
await self.downloader.apply_tags(
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
download_item.cover_url_template,
+19 -17
View File
@@ -1,19 +1,21 @@
class MediaNotStreamableError(Exception):
def __init__(self, media_id: str):
class GamdlNotStreamableError(Exception):
def __init__(self):
super().__init__("Media is not streamable")
class GamdlFormatNotAvailableError(Exception):
def __init__(self):
super().__init__("Media is not available in the requested format")
class GamdlExecutableNotFoundError(Exception):
def __init__(self, executable: str):
super().__init__(f"{executable} was not found in system PATH")
class GamdlSyncedLyricsOnlyError(Exception):
def __init__(self):
super().__init__(
f'Media with ID "{media_id}" is not streamable'.format(media_id=media_id)
)
class MediaFormatNotAvailableError(Exception):
def __init__(self, media_id: str):
super().__init__(
f'Media with ID "{media_id}" is not available in the requested format'
)
class MediaDownloadConfigurationError(Exception):
def __init__(self, media_id: str):
super().__init__(
f'Media with ID "{media_id}" is not downloadable with the current configuration'
"Cannot download media because downloader is configured to download "
"synced lyrics only"
)
+4
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from typing import Any
from ..interface.types import (
DecryptionKeyAv,
@@ -12,6 +13,7 @@ from ..interface.types import (
@dataclass
class DownloadItem:
media_metadata: dict = None
playlist_metadata: dict = None
random_uuid: str = None
lyrics: Lyrics = None
media_tags: MediaTags = None
@@ -24,6 +26,8 @@ class DownloadItem:
playlist_file_path: str = None
synced_lyrics_path: str = None
cover_path: str = None
flat_filter_result: Any = None
error: Exception = None
@dataclass
+5 -2
View File
@@ -1,3 +1,4 @@
import asyncio
import base64
import datetime
import logging
@@ -41,7 +42,9 @@ class AppleMusicInterface:
pssh_obj = PSSH(track_uri.split(",")[-1])
challenge = base64.b64encode(
cdm.get_license_challenge(cdm_session, pssh_obj)
await asyncio.to_thread(
cdm.get_license_challenge, cdm_session, pssh_obj
)
).decode()
license = await self.apple_music_api.get_license_exchange(
track_id,
@@ -49,7 +52,7 @@ class AppleMusicInterface:
challenge,
)
cdm.parse_license(cdm_session, license["license"])
await asyncio.to_thread(cdm.parse_license, cdm_session, license["license"])
decryption_key_info = next(
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
)
+37 -36
View File
@@ -16,19 +16,16 @@ from .types import DecryptionKeyAv, MediaFileFormat, MediaTags, StreamInfo, Stre
logger = logging.getLogger(__name__)
class AppleMusicMusicVideoInterface:
def __init__(
self,
interface: AppleMusicInterface,
):
self.interface = interface
class AppleMusicMusicVideoInterface(AppleMusicInterface):
def __init__(self, interface: AppleMusicInterface):
self.__dict__.update(interface.__dict__)
async def get_itunes_page_metadata(
self,
music_video_metadata: dict,
) -> dict:
alt_id = self.get_alt_id(music_video_metadata)
itunes_page = await self.interface.itunes_api.get_itunes_page(
itunes_page = await self.itunes_api.get_itunes_page(
"music-video",
alt_id,
)
@@ -69,7 +66,7 @@ class AppleMusicMusicVideoInterface:
self,
collection_id: int,
) -> dict | None:
album_response = await self.interface.apple_music_api.get_album(collection_id)
album_response = await self.apple_music_api.get_album(collection_id)
if not album_response:
return None
return album_response["data"][0]
@@ -80,9 +77,7 @@ class AppleMusicMusicVideoInterface:
itunes_page_metadata: dict,
) -> MediaTags:
alt_id = self.get_alt_id(metadata)
lookup_metadata = (await self.interface.itunes_api.get_lookup_result(alt_id))[
"results"
]
lookup_metadata = (await self.itunes_api.get_lookup_result(alt_id))["results"]
explicitness = lookup_metadata[0]["trackExplicitness"]
if explicitness == "notExplicit":
@@ -96,11 +91,11 @@ class AppleMusicMusicVideoInterface:
artist=lookup_metadata[0]["artistName"],
artist_id=int(lookup_metadata[0]["artistId"]),
copyright=itunes_page_metadata.get("copyright"),
date=self.interface.parse_date(lookup_metadata[0]["releaseDate"]),
date=self.parse_date(lookup_metadata[0]["releaseDate"]),
genre=lookup_metadata[0]["primaryGenreName"],
genre_id=int(itunes_page_metadata["genres"][0]["genreId"]),
media_type=MediaType.MUSIC_VIDEO,
storefront=int(self.interface.itunes_api.storefront_id.split("-")[0]),
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
title=lookup_metadata[0]["trackCensoredName"],
title_id=int(metadata["id"]),
rating=rating,
@@ -137,7 +132,7 @@ class AppleMusicMusicVideoInterface:
itunes_page_metadata,
)
else:
webplayback_response = await self.interface.apple_music_api.get_webplayback(
webplayback_response = await self.apple_music_api.get_webplayback(
metadata["id"]
)
m3u8_master_url = self.get_m3u8_master_url_from_webplayback(
@@ -180,31 +175,37 @@ class AppleMusicMusicVideoInterface:
def get_video_playlist_from_resolution(
self,
video_playlists: list[m3u8.Playlist],
codec: MusicVideoCodec,
codec_priority: list[MusicVideoCodec],
resolution: MusicVideoResolution,
) -> m3u8.Playlist | None:
playlists_filtered = [
playlist
for playlist in video_playlists
if playlist.stream_info.codecs.startswith(codec.fourcc())
]
if not playlists_filtered:
playlist_results = []
for codec_index, codec in enumerate(codec_priority):
for playlist in video_playlists:
if playlist.stream_info.codecs.startswith(codec.fourcc()):
playlist_results.append((codec_index, playlist))
if not playlist_results:
return None
def sort_key(playlist: m3u8.Playlist) -> tuple[int, int, int, int]:
def sort_key(
item: tuple[int, m3u8.Playlist],
) -> tuple[bool, int, int, int, int]:
codec_index, playlist = item
playlist_resolution = playlist.stream_info.resolution[-1]
resolution_difference = abs(playlist_resolution - int(resolution))
bandwidth = playlist.stream_info.bandwidth
exceeds_resolution = playlist_resolution > int(resolution)
resolution_difference = abs(playlist_resolution - int(resolution))
return (
exceeds_resolution,
resolution_difference,
codec_index,
-playlist_resolution,
-bandwidth,
)
playlists_filtered.sort(key=sort_key)
return playlists_filtered[0]
playlist_results.sort(key=sort_key)
return playlist_results[0][1]
def get_best_stereo_audio_playlist(
self,
@@ -282,14 +283,11 @@ class AppleMusicMusicVideoInterface:
stream_info = StreamInfo()
if MusicVideoCodec.ASK not in codec_priority:
for codec in codec_priority:
playlist = self.get_video_playlist_from_resolution(
playlist_master_m3u8_obj.playlists,
codec,
resolution,
)
if playlist:
break
playlist = self.get_video_playlist_from_resolution(
playlist_master_m3u8_obj.playlists,
codec_priority,
resolution,
)
else:
playlist = await self.get_video_playlist_from_user(
playlist_master_m3u8_obj.playlists
@@ -300,6 +298,7 @@ class AppleMusicMusicVideoInterface:
stream_info.stream_url = playlist.uri
stream_info.codec = playlist.stream_info.codecs
stream_info.width, stream_info.height = playlist.stream_info.resolution
playlist_m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj)
@@ -334,12 +333,14 @@ class AppleMusicMusicVideoInterface:
stream_info: StreamInfoAv,
cdm: Cdm,
) -> DecryptionKeyAv:
decryption_key_video = await self.interface.get_decryption_key(
decryption_key_video = await AppleMusicInterface.get_decryption_key(
self,
stream_info.video_track.widevine_pssh,
stream_info.media_id,
cdm,
)
decryption_key_audio = await self.interface.get_decryption_key(
decryption_key_audio = await AppleMusicInterface.get_decryption_key(
self,
stream_info.audio_track.widevine_pssh,
stream_info.media_id,
cdm,
+24 -21
View File
@@ -1,3 +1,4 @@
import asyncio
import base64
import datetime
import json
@@ -29,12 +30,9 @@ from .types import (
logger = logging.getLogger(__name__)
class AppleMusicSongInterface:
def __init__(
self,
interface: AppleMusicInterface,
) -> None:
self.interface = interface
class AppleMusicSongInterface(AppleMusicInterface):
def __init__(self, interface: AppleMusicInterface):
self.__dict__.update(interface.__dict__)
async def get_lyrics(
self,
@@ -49,8 +47,8 @@ class AppleMusicSongInterface:
or "lyrics" not in song_metadata["relationships"]
):
song_metadata = (
await self.interface.apple_music_api.get_song(
self.interface.get_media_id_of_library_media(song_metadata)
await self.apple_music_api.get_song(
self.get_media_id_of_library_media(song_metadata)
)
)["data"][0]
@@ -109,9 +107,11 @@ class AppleMusicSongInterface:
index += 1
return Lyrics(
synced="\n".join(synced_lyrics + ["\n"]),
unsynced="\n\n".join(
["\n".join(lyric_group) for lyric_group in unsynced_lyrics]
synced="\n".join(synced_lyrics + ["\n"]) if synced_lyrics else None,
unsynced=(
"\n\n".join(["\n".join(lyric_group) for lyric_group in unsynced_lyrics])
if unsynced_lyrics
else None
),
)
@@ -194,7 +194,7 @@ class AppleMusicSongInterface:
composer_sort=webplayback_metadata.get("sort-composer"),
copyright=webplayback_metadata.get("copyright"),
date=(
self.interface.parse_date(webplayback_metadata["releaseDate"])
self.parse_date(webplayback_metadata["releaseDate"])
if webplayback_metadata.get("releaseDate")
else None
),
@@ -414,17 +414,19 @@ class AppleMusicSongInterface:
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
challenge = base64.b64encode(
cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license_response = (
await self.interface.apple_music_api.get_license_exchange(
stream_info.media_id,
stream_info.audio_track.widevine_pssh,
challenge,
await asyncio.to_thread(
cdm.get_license_challenge, cdm_session, pssh_obj
)
).decode()
license_response = await self.apple_music_api.get_license_exchange(
stream_info.media_id,
stream_info.audio_track.widevine_pssh,
challenge,
)
cdm.parse_license(cdm_session, license_response["license"])
await asyncio.to_thread(
cdm.parse_license, cdm_session, license_response["license"]
)
decryption_key = next(
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
@@ -448,7 +450,8 @@ class AppleMusicSongInterface:
cdm: Cdm,
) -> DecryptionKeyAv:
return DecryptionKeyAv(
audio_track=await self.interface.get_decryption_key(
audio_track=await AppleMusicInterface.get_decryption_key(
self,
stream_info.audio_track.widevine_pssh,
stream_info.media_id,
cdm,
+5 -5
View File
@@ -7,14 +7,14 @@ from ..interface.enums import UploadedVideoQuality
from ..interface.types import MediaTags
from .constants import UPLOADED_VIDEO_QUALITY_RANK
from .interface import AppleMusicInterface
from .types import StreamInfo, StreamInfoAv, MediaFileFormat
from .types import MediaFileFormat, StreamInfo, StreamInfoAv
logger = logging.getLogger(__name__)
class AppleMusicUploadedVideoInterface:
class AppleMusicUploadedVideoInterface(AppleMusicInterface):
def __init__(self, interface: AppleMusicInterface):
self.interface = interface
self.__dict__.update(interface.__dict__)
def get_stream_url_best(self, metadata: dict) -> str:
best_quality = next(
@@ -76,10 +76,10 @@ class AppleMusicUploadedVideoInterface:
tags = MediaTags(
artist=attributes.get("artistName"),
date=self.interface.parse_date(upload_date) if upload_date else None,
date=self.parse_date(upload_date) if upload_date else None,
title=attributes.get("name"),
title_id=int(metadata["id"]),
storefront=int(self.interface.itunes_api.storefront_id.split("-")[0]),
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
)
logger.debug(f"Tags: {tags}")
+41 -38
View File
@@ -44,22 +44,18 @@ class MediaTags:
def as_mp4_tags(self, date_format: str = None) -> dict:
disc_mp4 = [
[
self.disc if self.disc is not None else 0,
self.disc_total if self.disc_total is not None else 0,
]
self.disc if self.disc is not None else 0,
self.disc_total if self.disc_total is not None else 0,
]
if disc_mp4[0][0] == 0 and disc_mp4[0][1] == 0:
disc_mp4 = [None]
if disc_mp4[0] == 0 and disc_mp4[1] == 0:
disc_mp4 = None
track_mp4 = [
[
self.track if self.track is not None else 0,
self.track_total if self.track_total is not None else 0,
]
self.track if self.track is not None else 0,
self.track_total if self.track_total is not None else 0,
]
if track_mp4[0][0] == 0 and track_mp4[0][1] == 0:
track_mp4 = [None]
if track_mp4[0] == 0 and track_mp4[1] == 0:
track_mp4 = None
if isinstance(self.date, datetime.date):
if date_format is None:
@@ -72,35 +68,40 @@ class MediaTags:
date_mp4 = None
mp4_tags = {
"\xa9alb": [self.album],
"aART": [self.album_artist],
"plID": [self.album_id],
"soal": [self.album_sort],
"\xa9ART": [self.artist],
"atID": [self.artist_id],
"soar": [self.artist_sort],
"\xa9cmt": [self.comment],
"cpil": [bool(self.compilation) if self.compilation is not None else None],
"\xa9wrt": [self.composer],
"cmID": [self.composer_id],
"soco": [self.composer_sort],
"cprt": [self.copyright],
"\xa9day": [date_mp4],
"\xa9alb": self.album,
"aART": self.album_artist,
"plID": self.album_id,
"soal": self.album_sort,
"\xa9ART": self.artist,
"atID": self.artist_id,
"soar": self.artist_sort,
"\xa9cmt": self.comment,
"cpil": bool(self.compilation) if self.compilation is not None else None,
"\xa9wrt": self.composer,
"cmID": self.composer_id,
"soco": self.composer_sort,
"cprt": self.copyright,
"\xa9day": date_mp4,
"disk": disc_mp4,
"pgap": [bool(self.gapless) if self.gapless is not None else None],
"\xa9gen": [self.genre],
"\xa9lyr": [self.lyrics],
"geID": [self.genre_id],
"stik": [int(self.media_type) if self.media_type is not None else None],
"rtng": [int(self.rating) if self.rating is not None else None],
"sfID": [self.storefront],
"\xa9nam": [self.title],
"cnID": [self.title_id],
"sonm": [self.title_sort],
"pgap": bool(self.gapless) if self.gapless is not None else None,
"\xa9gen": self.genre,
"\xa9lyr": self.lyrics,
"geID": self.genre_id,
"stik": int(self.media_type) if self.media_type is not None else None,
"rtng": int(self.rating) if self.rating is not None else None,
"sfID": self.storefront,
"\xa9nam": self.title,
"cnID": self.title_id,
"sonm": self.title_sort,
"trkn": track_mp4,
"xid ": [self.xid],
"xid ": self.xid,
}
return {
k: ([v] if not isinstance(v, bool) else v)
for k, v in mp4_tags.items()
if v is not None
}
return {k: v for k, v in mp4_tags.items() if v[0] is not None}
@dataclass
@@ -118,6 +119,8 @@ class StreamInfo:
playready_pssh: str = None
fairplay_key: str = None
codec: str = None
width: int = None
height: int = None
@dataclass
+1 -1
View File
@@ -48,7 +48,7 @@ async def async_subprocess(*args: str, silent: bool = False) -> None:
async def safe_gather(
*tasks: typing.Awaitable[typing.Any],
limit: int = 5,
limit: int = 3,
retries: int = 3,
) -> list[typing.Any]:
semaphore = asyncio.Semaphore(limit)
+4 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gamdl"
version = "2.7"
version = "2.7.4"
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
readme = "README.md"
license = { text = "MIT" }
@@ -17,5 +17,8 @@ dependencies = [
"yt-dlp>=2025.10.22",
]
[project.urls]
Repository = "https://github.com/glomatico/gamdl"
[project.scripts]
gamdl = "gamdl.cli.cli:main"