Compare commits

...

64 Commits

Author SHA1 Message Date
Rafael Moraes 29f68f6bc4 Bump version to 2.9.2 2026-03-05 15:08:42 -03:00
Rafael Moraes e77c6b24b4 Merge pull request #277 from LiuqingDu/fix-all-albums
Fix KeyError during artist download pagination
2026-03-05 15:07:16 -03:00
Liuqing Du ba315dcb95 Fix KeyError during artist download pagination 2026-02-28 11:50:52 -06:00
Rafael Moraes 4187fad734 Bump version to 2.9.1 2026-02-25 19:13:13 -03:00
Rafael Moraes f36edf4bbd Add 'Apple Music Classical' to README 2026-02-25 19:12:29 -03:00
Rafael Moraes 50478d427e Add Artist Auto-Select options to README 2026-02-25 19:11:20 -03:00
Rafael Moraes 45461007a9 Add artist auto select flag; rename song codec flag 2026-02-25 19:07:33 -03:00
Rafael Moraes 79a03d4f4c Rename artist_selection to artist_auto_select in CLI 2026-02-25 19:05:07 -03:00
Rafael Moraes beb508529a Rename ArtistDownloadSelection to ArtistAutoSelect 2026-02-25 19:04:52 -03:00
Rafael Moraes 87cf8c7789 Add artist_selection CLI option 2026-02-25 19:01:57 -03:00
Rafael Moraes 9e3f740eec Add ArtistDownloadSelection and auto-select option 2026-02-25 19:01:37 -03:00
Rafael Moraes 7281f5c949 Support song codec priority list 2026-02-25 18:16:34 -03:00
Rafael Moraes d32781b23f Skip wrapper decryption for legacy codecs 2026-02-25 17:52:15 -03:00
Rafael Moraes 5f2c74399e Merge pull request #276 from symphoniacus/fix-classical-url-parsing
fix: add support for Apple Music Classical URLs
2026-02-25 17:48:15 -03:00
Rafael Moraes 6b67c435fa Fix spacing in CLI warning message 2026-02-25 15:12:46 -03:00
Rafael Moraes 240ba7d4de Handle 404 ApiError for Apple Music calls 2026-02-25 15:09:52 -03:00
Rafael Moraes 02c19963b4 Clarify wrapper requirements in README 2026-02-25 14:55:33 -03:00
Rafael Moraes 2e2fef1426 Bump version to 2.9 2026-02-25 14:54:28 -03:00
Rafael Moraes ae3b2e1c6d Skip fetching covers when CoverFormat.RAW 2026-02-25 14:48:47 -03:00
Rafael Moraes 6516855be9 Fix Apple Music cover URL and async image read 2026-02-25 14:48:35 -03:00
Rafael Moraes 77cbb8a7ca Clarify README prerequisites and config table 2026-02-25 14:33:50 -03:00
Rafael Moraes 18bc6595a9 Add music_video_remux_mode and adjust checks 2026-02-25 14:33:32 -03:00
Rafael Moraes da2c3d5f1e Move remux_mode to music video downloader 2026-02-25 14:33:08 -03:00
Rafael Moraes abe364aad1 Remove unused imports in downloader_song.py 2026-02-25 14:32:29 -03:00
Rafael Moraes 10b529d6fd Remove hardcoded song decryption key 2026-02-25 14:08:57 -03:00
Rafael Moraes afe42848d0 Refactor song decryption and staging 2026-02-25 14:08:35 -03:00
Rafael Moraes b3b5e6d1b2 Add sample encryption parsing and hex-key decryption 2026-02-25 14:08:09 -03:00
Rafael Moraes 9f86c7436d Bump version to 2.8.7 2026-02-25 12:36:31 -03:00
Rafael Moraes 74a26d0342 Preserve original moov boxes and metadata 2026-02-25 12:30:29 -03:00
Rafael Moraes 37895dea1c Add AI-generated notice to amdecrypt.py 2026-02-25 00:13:58 -03:00
Rafael Moraes 04396a7f3f Bump version to 2.8.6 2026-02-25 00:09:53 -03:00
Rafael Moraes bde49305c9 Select audio track for moof/mdat extraction 2026-02-25 00:08:36 -03:00
Rafael Moraes b0c3b4630d Make decrypt_samples async and use asyncio streams 2026-02-24 23:09:32 -03:00
Rafael Moraes fd30ab861b Update help text for --use-wrapper 2026-02-23 23:56:06 -03:00
Rafael Moraes b1827e8d1b Bump version to 2.8.5 2026-02-23 23:50:47 -03:00
Rafael Moraes fe020442b1 Fetch song details when extendedAssetUrls missing 2026-02-23 23:50:20 -03:00
Rafael Moraes 87b8492b4f Include legacy codec in wrapper bypass check 2026-02-23 23:46:54 -03:00
Rafael Moraes f961ade8d8 Remove forced AAC override for wrapper usage 2026-02-23 23:46:40 -03:00
Rafael Moraes 471a2e85ac Include offset from next_uri in AMP requests 2026-02-23 23:43:47 -03:00
Rafael Moraes a17b1296d8 Fix spacing in wrapper codec warning 2026-02-23 23:31:33 -03:00
Rafael Moraes 22628c4c53 Bypass wrapper for music videos 2026-02-23 23:30:46 -03:00
Rafael Moraes 23a5be37b1 Handle wrapper: skip exec checks and adjust codec 2026-02-23 23:18:28 -03:00
Rafael Moraes 9aa7a2e199 Use media_type_key for music-videos check 2026-02-23 23:09:19 -03:00
Rafael Moraes 31d07172a6 Include live albums in artist views 2026-02-23 23:07:09 -03:00
Rafael Moraes fbe0167f0e Add live albums support 2026-02-23 23:06:56 -03:00
Rafael Moraes 1d621568a0 README: simplify wrapper docs and config 2026-02-23 22:59:51 -03:00
Rafael Moraes fa31649d76 Preserve moov box timestamps in decrypted m4a 2026-02-23 22:57:03 -03:00
Rafael Moraes 16d8dc925a Handle wrapper connect errors; remove amdecrypt 2026-02-23 22:00:38 -03:00
Rafael Moraes 46d1ec11dc Add Python amdecrypt and remove amdecrypt dep 2026-02-23 22:00:22 -03:00
Rafael Moraes f68e76ce8b Add ApiError and centralize AMP requests 2026-02-23 21:52:53 -03:00
Rafael Moraes 42df1f7f5e Make safe_json return None on parse error 2026-02-23 21:44:47 -03:00
symphoniacus d11e937c6a fix: allow Apple Music Classical URLs (classical.music.apple.com) 2026-02-14 19:24:56 +01:00
Rafael Moraes a7c8ff4297 Fix relative import for GamdlError in exceptions.py 2026-01-30 12:22:30 -03:00
Rafael Moraes 5332e0e1c0 Move GamdlError to utils and update imports 2026-01-30 12:21:39 -03:00
Rafael Moraes b8ea1d0039 Add support for downloading artist top songs 2026-01-24 10:54:55 -03:00
Rafael Moraes 4de0e3d1f8 Add 'views' parameter to artist API request 2026-01-24 10:54:49 -03:00
Rafael Moraes c770ff361f Refactor config file loading with decorator
Introduced ConfigFile.loader decorator to handle config file loading in CLI entrypoint. Removed manual config file loading logic from main function for improved modularity and readability.
2026-01-17 01:37:49 -03:00
Rafael Moraes d6afb680be Exclude help and version from CLI config parsing 2026-01-16 23:12:48 -03:00
Rafael Moraes b15f404849 Refactor config file loading in CLI 2026-01-16 23:06:48 -03:00
Rafael Moraes 072d71caaf Remove explicit click param types from CLI config 2026-01-16 22:53:14 -03:00
Rafael Moraes 7e132c27de Refactor config parameter handling to use Click params 2026-01-16 22:49:45 -03:00
Rafael Moraes 073f70afa7 Bump version to 2.8.4 2026-01-15 23:43:24 -03:00
Rafael Moraes a49430018a Remove setuptools packages config from pyproject.toml 2026-01-15 23:42:59 -03:00
Rafael Moraes f0450b93c7 Update import to use relative path in __main__.py 2026-01-15 23:42:55 -03:00
24 changed files with 2487 additions and 698 deletions
+71 -67
View File
@@ -26,18 +26,16 @@ A command-line app for downloading Apple Music songs, music videos and post vide
- **Apple Music Cookies** - Export your browser cookies in Netscape format while logged in with an active subscription at the Apple Music website:
- **Firefox**: [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt)
- **Chromium**: [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
- **FFmpeg** - Must be in your system PATH
- **Windows**: [AnimMouse's FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases)
- **Linux**: [John Van Sickle's FFmpeg Builds](https://johnvansickle.com/ffmpeg/)
### Optional
Add these tools to your system PATH for additional features:
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required for `mp4box` remux mode, music videos, and experimental codecs
- **[MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)** - Required for `mp4box` remux mode
- **[FFmpeg](https://ffmpeg.org/download.html)** - Required for `ffmpeg` music video remux mode
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required for `mp4box` music video remux mode
- **[MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)** - Required for `mp4box` music video remux mode
- **[N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)** - Required for `nm3u8dlre` download mode, which is faster than the default downloader
- **[Wrapper & amdecrypt](#-wrapper--amdecrypt)** - For downloading songs in ALAC and other experimental codecs without API limitations
- **[Wrapper](#-wrapper)** - For downloading songs in ALAC and other experimental codecs without API limitations
## 📦 Installation
@@ -66,6 +64,7 @@ gamdl [OPTIONS] URLS...
- Music Videos
- Artists
- Post Videos
- Apple Music Classical
### Examples
@@ -109,62 +108,63 @@ 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` |
| `--wrapper-account-url` | Wrapper account URL | `http://127.0.0.1:30020` |
| `--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` |
| `--amdecrypt-path` | amdecrypt executable path | `amdecrypt` |
| `--use-wrapper` | Use wrapper and amdecrypt | `false` |
| `--wrapper-decrypt-ip` | Wrapper decryption server IP | `127.0.0.1:10020` |
| `--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` |
| `--use-album-date` | Use album release date for songs | `false` |
| 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` |
| `--wrapper-account-url` | Wrapper account URL | `http://127.0.0.1:30020` |
| `--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** | | |
| `--artist-auto-select` | Automatically select artist content to download (artist URLs) | - |
| `--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` |
| `--use-wrapper` | Use wrapper | `false` |
| `--wrapper-decrypt-ip` | Wrapper decryption server IP | `127.0.0.1:10020` |
| `--download-mode` | Download mode | `ytdlp` |
| `--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-priority` | Comma-separated codec priority | `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` |
| `--use-album-date` | Use album release date for songs | `false` |
| `--fetch-extra-tags` | Fetch extra tags from preview (normalization and smooth playback) | `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` |
| **Music Video Options** | | |
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
| `--music-video-remux-mode` | Remux mode | `ffmpeg` |
| `--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
@@ -255,15 +255,19 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
- `best` - Up to 1080p with AAC 256kbps
- `ask` - Interactive quality selection
## ⚙️ Wrapper & amdecrypt
### Artist Auto-Select Options
Use the [wrapper](https://github.com/WorldObservationLog/wrapper) and [amdecrypt](https://github.com/glomatico/amdecrypt) to download songs in ALAC and other experimental codecs without API limitations. Cookies are not required when using the wrapper.
- `main-albums`
- `compilation-albums`
- `live-albums`
- `singles-eps`
- `all-albums`
- `top-songs`
- `music-videos`
### Prerequisites
## ⚙️ Wrapper
- **[wrapper](https://github.com/WorldObservationLog/wrapper)** - Refer to the repository for installation
- **[amdecrypt](https://github.com/glomatico/amdecrypt)** - Refer to the repository for installation
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required by amdecrypt to decrypt protected files
Use the [wrapper](https://github.com/WorldObservationLog/wrapper) to download songs in ALAC and other experimental codecs without API limitations. Cookies are not required when using the wrapper.
### Setup Instructions
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.8.3"
__version__ = "2.9.2"
+1 -1
View File
@@ -1,3 +1,3 @@
from gamdl.cli.cli import main
from .cli.cli import main
main()
+90 -130
View File
@@ -14,6 +14,7 @@ from .constants import (
LICENSE_API_URL,
WEBPLAYBACK_API_URL,
)
from .exceptions import ApiError
logger = logging.getLogger(__name__)
@@ -130,7 +131,6 @@ class AppleMusicApi:
async def _get_token(self) -> str:
response = await self.client.get(APPLE_MUSIC_HOMEPAGE_URL)
raise_for_status(response)
home_page = response.text
index_js_uri_match = re.search(
@@ -142,7 +142,6 @@ class AppleMusicApi:
index_js_uri = index_js_uri_match.group(1)
response = await self.client.get(f"{APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}")
raise_for_status(response)
index_js_page = response.text
token_match = re.search('(?=eyJh)(.*?)(?=")', index_js_page)
@@ -186,43 +185,53 @@ class AppleMusicApi:
return None
return data[0].get("attributes", {}).get("restrictions")
async def get_account_info(self, meta: str | None = "subscription") -> dict:
response = await self.client.get(
f"{AMP_API_URL}/v1/me/account",
params={
**({"meta": meta} if meta else {}),
async def get_account_info(self, meta: str = "subscription") -> dict:
account_info = await self._amp_request(
f"/v1/me/account",
{
"meta": meta,
},
)
raise_for_status(response)
account_info = safe_json(response)
if not "data" in account_info or (meta and "meta" not in account_info):
raise Exception("Error getting account info:", response.text)
logger.debug(f"Account info: {account_info}")
return account_info
async def _amp_request(
self,
endpoint: str,
params: dict | None = None,
) -> dict:
response = await self.client.get(
AMP_API_URL + endpoint,
params=params or {},
)
response_json = safe_json(response)
if (
response.status_code != 200
or response_json is None
or "errors" in response_json
):
raise ApiError(
message=response.text,
status_code=response.status_code,
)
return response_json
async def get_song(
self,
song_id: str,
extend: str = "extendedAssetUrls",
include: str = "lyrics,albums",
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/songs/{song_id}",
params={
song = await self._amp_request(
f"/v1/catalog/{self.storefront}/songs/{song_id}",
{
"extend": extend,
"include": include,
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
song = safe_json(response)
if not "data" in song:
raise Exception("Error getting song:", response.text)
logger.debug(f"Song: {song}")
return song
@@ -232,20 +241,12 @@ class AppleMusicApi:
music_video_id: str,
include: str = "albums",
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/music-videos/{music_video_id}",
params={
music_video = await self._amp_request(
f"/v1/catalog/{self.storefront}/music-videos/{music_video_id}",
{
"include": include,
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
music_video = safe_json(response)
if not "data" in music_video:
raise Exception("Error getting music video:", response.text)
logger.debug(f"Music video: {music_video}")
return music_video
@@ -254,17 +255,9 @@ class AppleMusicApi:
self,
post_id: str,
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/uploaded-videos/{post_id}"
uploaded_video = await self._amp_request(
f"/v1/catalog/{self.storefront}/uploaded-videos/{post_id}",
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
uploaded_video = safe_json(response)
if not "data" in uploaded_video:
raise Exception("Error getting uploaded video:", response.text)
logger.debug(f"Uploaded video: {uploaded_video}")
return uploaded_video
@@ -274,20 +267,12 @@ class AppleMusicApi:
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/albums/{album_id}",
params={
album = await self._amp_request(
f"/v1/catalog/{self.storefront}/albums/{album_id}",
{
"extend": extend,
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
album = safe_json(response)
if not "data" in album:
raise Exception("Error getting album:", response.text)
logger.debug(f"Album: {album}")
return album
@@ -298,21 +283,13 @@ class AppleMusicApi:
limit_tracks: int = 300,
extend: str = "extendedAssetUrls",
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}",
params={
playlist = await self._amp_request(
f"/v1/catalog/{self.storefront}/playlists/{playlist_id}",
{
"limit[tracks]": limit_tracks,
"extend": extend,
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
playlist = safe_json(response)
if not "data" in playlist:
raise Exception("Error getting playlist:", response.text)
logger.debug(f"Playlist: {playlist}")
return playlist
@@ -321,23 +298,20 @@ class AppleMusicApi:
self,
artist_id: str,
include: str = "albums,music-videos",
views: str = "full-albums,compilation-albums,live-albums,singles,top-songs",
limit: int = 100,
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
params={
artist = await self._amp_request(
f"/v1/catalog/{self.storefront}/artists/{artist_id}",
{
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
"views": views,
**{
f"limit[{_include}]": limit
for _include in [*include.split(","), *views.split(",")]
},
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
artist = safe_json(response)
if not "data" in artist:
raise Exception("Error getting artist:", response.text)
logger.debug(f"Artist: {artist}")
return artist
@@ -347,20 +321,12 @@ class AppleMusicApi:
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/me/library/albums/{album_id}",
params={
album = await self._amp_request(
f"/v1/me/library/albums/{album_id}",
{
"extend": extend,
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
album = safe_json(response)
if not "data" in album:
raise Exception("Error getting library album:", response.text)
logger.debug(f"Library album: {album}")
return album
@@ -372,22 +338,15 @@ class AppleMusicApi:
limit: int = 100,
extend: str = "extendedAssetUrls",
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/me/library/playlists/{playlist_id}",
params={
playlist = await self._amp_request(
f"/v1/me/library/playlists/{playlist_id}",
{
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
"extend": extend,
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
playlist = safe_json(response)
if not "data" in playlist:
raise Exception("Error getting library playlist:", response.text)
logger.debug(f"Library playlist: {playlist}")
return playlist
@@ -398,20 +357,15 @@ class AppleMusicApi:
limit: int = 50,
offset: int = 0,
) -> dict:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/search",
params={
search_results = await self._amp_request(
f"/v1/catalog/{self.storefront}/search",
{
"term": term,
"types": types,
"limit": limit,
"offset": offset,
},
)
raise_for_status(response)
search_results = safe_json(response)
if not "results" in search_results:
raise Exception("Error searching:", response.text)
logger.debug(f"Search results: {search_results}")
return search_results
@@ -442,19 +396,13 @@ class AppleMusicApi:
limit: int,
extend: str,
) -> dict:
response = await self.client.get(
AMP_API_URL + next_uri,
params={
"limit": limit,
"extend": extend,
**parse_qs(urlparse(next_uri).query),
},
)
raise_for_status(response)
extended_api_data = safe_json(response)
if not "data" in extended_api_data:
raise Exception("Error getting extended API data:", response.text)
next_uri_params = parse_qs(urlparse(next_uri).query)
params = {
"limit": limit,
"offset": next_uri_params["offset"][0],
"extend": extend,
}
extended_api_data = await self._amp_request(next_uri, params)
logger.debug(f"Extended API data: {extended_api_data}")
return extended_api_data
@@ -470,12 +418,17 @@ class AppleMusicApi:
"language": self.language,
},
)
raise_for_status(response)
webplayback = safe_json(response)
if not "songList" in webplayback:
raise Exception("Error getting webplayback:", response.text)
logger.debug(f"Webplayback: {webplayback}")
if (
response.status_code != 200
or webplayback is None
or "dialog" in webplayback
):
raise ApiError(
message=response.text,
status_code=response.status_code,
)
return webplayback
@@ -497,11 +450,18 @@ class AppleMusicApi:
"user-initiated": True,
},
)
raise_for_status(response)
license_exchange = safe_json(response)
if not "license" in license_exchange:
raise Exception("Error getting license exchange:", response.text)
if (
response.status_code != 200
or license_exchange is None
or license_exchange.get("status") != 0
):
raise ApiError(
message=response.text,
status_code=response.status_code,
)
logger.debug(f"License exchange: {license_exchange}")
return license_exchange
+7
View File
@@ -0,0 +1,7 @@
from ..utils import GamdlError
class ApiError(GamdlError):
def __init__(self, message: str, status_code: int):
super().__init__(f"API Error {status_code}: {message}")
self.status_code = status_code
+16 -9
View File
@@ -2,8 +2,9 @@ import logging
import httpx
from ..utils import raise_for_status, safe_json
from ..utils import safe_json
from .constants import ITUNES_LOOKUP_API_URL, ITUNES_PAGE_API_URL, STOREFRONT_IDS
from .exceptions import ApiError
logger = logging.getLogger(__name__)
@@ -52,11 +53,14 @@ class ItunesApi:
"entity": entity,
},
)
raise_for_status(response)
lookup_result = safe_json(response)
if "results" not in lookup_result:
raise Exception("Error getting lookup result:", response.text)
if response.status_code != 200 or lookup_result is None:
raise ApiError(
message=response.text,
status_code=response.status_code,
)
logger.debug(f"Lookup result: {lookup_result}")
return lookup_result
@@ -69,11 +73,14 @@ class ItunesApi:
response = await self.client.get(
f"{ITUNES_PAGE_API_URL}/{media_type}/{media_id}"
)
raise_for_status(response)
itunes_page = safe_json(response)
if "storePlatformData" not in itunes_page:
raise Exception("Error getting iTunes page:", response.text)
if response.status_code != 200 or itunes_page is None:
raise ApiError(
message=response.text,
status_code=response.status_code,
)
logger.debug(f"iTunes page: {itunes_page}")
return itunes_page
+54 -40
View File
@@ -6,6 +6,7 @@ from pathlib import Path
import click
import colorama
from dataclass_click import dataclass_click
from httpx import ConnectError
from .. import __version__
from ..api import AppleMusicApi, ItunesApi
@@ -47,6 +48,7 @@ def make_sync(func):
@click.help_option("-h", "--help")
@click.version_option(__version__, "-v", "--version")
@dataclass_click(CliConfig)
@ConfigFile.loader
@make_sync
async def main(config: CliConfig):
colorama.just_fix_windows_console()
@@ -66,17 +68,18 @@ async def main(config: CliConfig):
logger.info(f"Starting Gamdl {__version__}")
if not config.no_config_file:
config_file = ConfigFile(config.config_path)
config_file.cleanup_unknown_params()
config_file.add_params_default_to_config()
config = config_file.update_params_from_config(config)
if config.use_wrapper:
apple_music_api = await AppleMusicApi.create_from_wrapper(
wrapper_account_url=config.wrapper_account_url,
language=config.language,
)
try:
apple_music_api = await AppleMusicApi.create_from_wrapper(
wrapper_account_url=config.wrapper_account_url,
language=config.language,
)
except ConnectError:
logger.critical(
"Could not connect to the wrapper account API. "
"Make sure the wrapper is running and the URL is correct."
)
return
else:
cookies_path = prompt_path(config.cookies_path)
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
@@ -120,11 +123,9 @@ async def main(config: CliConfig):
mp4decrypt_path=config.mp4decrypt_path,
ffmpeg_path=config.ffmpeg_path,
mp4box_path=config.mp4box_path,
amdecrypt_path=config.amdecrypt_path,
use_wrapper=config.use_wrapper,
wrapper_decrypt_ip=config.wrapper_decrypt_ip,
download_mode=config.download_mode,
remux_mode=config.remux_mode,
cover_format=config.cover_format,
album_folder_template=config.album_folder_template,
compilation_folder_template=config.compilation_folder_template,
@@ -141,7 +142,7 @@ async def main(config: CliConfig):
song_downloader = AppleMusicSongDownloader(
base_downloader=base_downloader,
interface=song_interface,
codec=config.song_codec,
codec_priority=config.song_codec_piority,
synced_lyrics_format=config.synced_lyrics_format,
no_synced_lyrics=config.no_synced_lyrics,
synced_lyrics_only=config.synced_lyrics_only,
@@ -152,6 +153,7 @@ async def main(config: CliConfig):
base_downloader=base_downloader,
interface=music_video_interface,
codec_priority=config.music_video_codec_priority,
remux_mode=config.music_video_remux_mode,
remux_format=config.music_video_remux_format,
resolution=config.music_video_resolution,
)
@@ -166,30 +168,10 @@ async def main(config: CliConfig):
song_downloader=song_downloader,
music_video_downloader=music_video_downloader,
uploaded_video_downloader=uploaded_video_downloader,
artist_auto_select=config.artist_auto_select,
)
if not config.synced_lyrics_only:
if not base_downloader.full_ffmpeg_path and (
config.remux_mode == RemuxMode.FFMPEG
or config.download_mode == DownloadMode.NM3U8DLRE
):
logger.critical(X_NOT_IN_PATH.format("ffmpeg", config.ffmpeg_path))
return
if (
not base_downloader.full_mp4box_path
and config.remux_mode == RemuxMode.MP4BOX
):
logger.critical(X_NOT_IN_PATH.format("MP4Box", config.mp4box_path))
return
if not base_downloader.full_mp4decrypt_path and (
config.song_codec not in (SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY)
or config.remux_mode == RemuxMode.MP4BOX
):
logger.critical(X_NOT_IN_PATH.format("mp4decrypt", config.mp4decrypt_path))
return
if (
config.download_mode == DownloadMode.NM3U8DLRE
and not base_downloader.full_nm3u8dlre_path
@@ -197,14 +179,46 @@ async def main(config: CliConfig):
logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", config.nm3u8dlre_path))
return
if config.use_wrapper and not base_downloader.full_amdecrypt_path:
logger.critical(X_NOT_IN_PATH.format("amdecrypt", config.amdecrypt_path))
return
missing_music_video_paths = []
if not config.song_codec.is_legacy() and not config.use_wrapper:
if not base_downloader.full_ffmpeg_path and (
config.music_video_remux_mode == RemuxMode.FFMPEG
or config.download_mode == DownloadMode.NM3U8DLRE
):
missing_music_video_paths.append(
X_NOT_IN_PATH.format("ffmpeg", config.ffmpeg_path)
)
if (
not base_downloader.full_mp4box_path
and config.music_video_remux_mode == RemuxMode.MP4BOX
):
missing_music_video_paths.append(
X_NOT_IN_PATH.format("MP4Box", config.mp4box_path)
)
if not base_downloader.full_mp4decrypt_path and (
config.song_codec_piority
not in (SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY)
or config.music_video_remux_mode == RemuxMode.MP4BOX
):
missing_music_video_paths.append(
X_NOT_IN_PATH.format("mp4decrypt", config.mp4decrypt_path)
)
if missing_music_video_paths:
logger.warning(
"You have chosen an experimental song codec"
" without enabling wrapper."
"Music videos will not be downloaded due to missing dependencies:\n"
+ "\n".join(missing_music_video_paths)
)
if (
any(not codec.is_legacy() for codec in config.song_codec_piority)
and not config.use_wrapper
):
logger.warning(
"You have chosen an experimental song codec "
"without enabling wrapper. "
"They're not guaranteed to work due to API limitations."
)
+35 -71
View File
@@ -4,15 +4,16 @@ from pathlib import Path
from typing import Annotated
import click
from click.types import BoolParamType, FuncParamType, IntParamType, StringParamType
from dataclass_click import argument, option
from ..api import AppleMusicApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
AppleMusicMusicVideoDownloader,
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
ArtistAutoSelect,
DownloadMode,
RemuxFormatMusicVideo,
RemuxMode,
@@ -36,6 +37,7 @@ song_downloader_sig = inspect.signature(AppleMusicSongDownloader.__init__)
uploaded_video_downloader_sig = inspect.signature(
AppleMusicUploadedVideoDownloader.__init__
)
downloader_sig = inspect.signature(AppleMusicDownloader.__init__)
@dataclass
@@ -55,8 +57,6 @@ class CliConfig:
"--read-urls-as-txt",
"-r",
help="Read URLs from text files",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -102,8 +102,6 @@ class CliConfig:
option(
"--no-exceptions",
help="Don't print exceptions",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -129,7 +127,6 @@ class CliConfig:
"--wrapper-account-url",
help="Wrapper account URL",
default=api_from_wrapper_sig.parameters["wrapper_account_url"].default,
type=StringParamType(),
),
]
language: Annotated[
@@ -139,7 +136,16 @@ class CliConfig:
"-l",
help="Metadata language",
default=api_sig.parameters["language"].default,
type=StringParamType(),
),
]
# Downloader specific options
artist_auto_select: Annotated[
ArtistAutoSelect | None,
option(
"--artist-auto-select",
help="Automatically select artist content to download (only for artist URLs)",
default=downloader_sig.parameters["artist_auto_select"].default,
type=ArtistAutoSelect,
),
]
# Base Downloader specific options
@@ -191,8 +197,6 @@ class CliConfig:
option(
"--overwrite",
help="Overwrite existing files",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -202,8 +206,6 @@ class CliConfig:
"--save-cover",
"-s",
help="Save cover as separate file",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -212,8 +214,6 @@ class CliConfig:
option(
"--save-playlist",
help="Save M3U8 playlist file",
type=BoolParamType(),
default=False,
is_flag=True,
),
]
@@ -223,7 +223,6 @@ class CliConfig:
"--nm3u8dlre-path",
help="N_m3u8DL-RE executable path",
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
type=StringParamType(),
),
]
mp4decrypt_path: Annotated[
@@ -232,7 +231,6 @@ class CliConfig:
"--mp4decrypt-path",
help="mp4decrypt executable path",
default=base_downloader_sig.parameters["mp4decrypt_path"].default,
type=StringParamType(),
),
]
ffmpeg_path: Annotated[
@@ -241,7 +239,6 @@ class CliConfig:
"--ffmpeg-path",
help="FFmpeg executable path",
default=base_downloader_sig.parameters["ffmpeg_path"].default,
type=StringParamType(),
),
]
mp4box_path: Annotated[
@@ -250,25 +247,13 @@ class CliConfig:
"--mp4box-path",
help="MP4Box executable path",
default=base_downloader_sig.parameters["mp4box_path"].default,
type=StringParamType(),
),
]
amdecrypt_path: Annotated[
str,
option(
"--amdecrypt-path",
help="amdecrypt executable path",
default=base_downloader_sig.parameters["amdecrypt_path"].default,
type=StringParamType(),
),
]
use_wrapper: Annotated[
bool,
option(
"--use-wrapper",
help="Use wrapper and amdecrypt for decrypting songs",
default=False,
type=BoolParamType(),
help="Use wrapper for decrypting songs",
is_flag=True,
),
]
@@ -278,7 +263,6 @@ class CliConfig:
"--wrapper-decrypt-ip",
help="IP address and port for wrapper decryption",
default=base_downloader_sig.parameters["wrapper_decrypt_ip"].default,
type=StringParamType(),
),
]
download_mode: Annotated[
@@ -287,16 +271,7 @@ class CliConfig:
"--download-mode",
help="Download mode",
default=base_downloader_sig.parameters["download_mode"].default,
type=FuncParamType(DownloadMode),
),
]
remux_mode: Annotated[
RemuxMode,
option(
"--remux-mode",
help="Remux mode",
default=base_downloader_sig.parameters["remux_mode"].default,
type=FuncParamType(RemuxMode),
type=DownloadMode,
),
]
cover_format: Annotated[
@@ -305,7 +280,7 @@ class CliConfig:
"--cover-format",
help="Cover format",
default=base_downloader_sig.parameters["cover_format"].default,
type=FuncParamType(CoverFormat),
type=CoverFormat,
),
]
album_folder_template: Annotated[
@@ -314,7 +289,6 @@ class CliConfig:
"--album-folder-template",
help="Album folder template",
default=base_downloader_sig.parameters["album_folder_template"].default,
type=StringParamType(),
),
]
compilation_folder_template: Annotated[
@@ -325,7 +299,6 @@ class CliConfig:
default=base_downloader_sig.parameters[
"compilation_folder_template"
].default,
type=StringParamType(),
),
]
no_album_folder_template: Annotated[
@@ -334,7 +307,6 @@ class CliConfig:
"--no-album-folder-template",
help="No album folder template",
default=base_downloader_sig.parameters["no_album_folder_template"].default,
type=StringParamType(),
),
]
single_disc_file_template: Annotated[
@@ -343,7 +315,6 @@ class CliConfig:
"--single-disc-file-template",
help="Single disc file template",
default=base_downloader_sig.parameters["single_disc_file_template"].default,
type=StringParamType(),
),
]
multi_disc_file_template: Annotated[
@@ -352,7 +323,6 @@ class CliConfig:
"--multi-disc-file-template",
help="Multi disc file template",
default=base_downloader_sig.parameters["multi_disc_file_template"].default,
type=StringParamType(),
),
]
no_album_file_template: Annotated[
@@ -361,7 +331,6 @@ class CliConfig:
"--no-album-file-template",
help="No album file template",
default=base_downloader_sig.parameters["no_album_file_template"].default,
type=StringParamType(),
),
]
playlist_file_template: Annotated[
@@ -370,7 +339,6 @@ class CliConfig:
"--playlist-file-template",
help="Playlist file template",
default=base_downloader_sig.parameters["playlist_file_template"].default,
type=StringParamType(),
),
]
date_tag_template: Annotated[
@@ -379,7 +347,6 @@ class CliConfig:
"--date-tag-template",
help="Date tag template",
default=base_downloader_sig.parameters["date_tag_template"].default,
type=StringParamType(),
),
]
exclude_tags: Annotated[
@@ -397,7 +364,6 @@ class CliConfig:
"--cover-size",
help="Cover size in pixels",
default=base_downloader_sig.parameters["cover_size"].default,
type=IntParamType(),
),
]
truncate: Annotated[
@@ -406,17 +372,16 @@ class CliConfig:
"--truncate",
help="Max filename length",
default=base_downloader_sig.parameters["truncate"].default,
type=IntParamType(),
),
]
# DownloaderSong specific options
song_codec: Annotated[
SongCodec,
song_codec_piority: Annotated[
list[SongCodec],
option(
"--song-codec",
help="Song codec",
default=song_downloader_sig.parameters["codec"].default,
type=FuncParamType(SongCodec),
"--song-codec-priority",
help="Comma-separated codec priority",
default=song_downloader_sig.parameters["codec_priority"].default,
type=Csv(SongCodec),
),
]
synced_lyrics_format: Annotated[
@@ -425,7 +390,7 @@ class CliConfig:
"--synced-lyrics-format",
help="Synced lyrics format",
default=song_downloader_sig.parameters["synced_lyrics_format"].default,
type=FuncParamType(SyncedLyricsFormat),
type=SyncedLyricsFormat,
),
]
no_synced_lyrics: Annotated[
@@ -433,8 +398,6 @@ class CliConfig:
option(
"--no-synced-lyrics",
help="Don't download synced lyrics",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -443,8 +406,6 @@ class CliConfig:
option(
"--synced-lyrics-only",
help="Download only synced lyrics",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -453,8 +414,6 @@ class CliConfig:
option(
"--use-album-date",
help="Use album release date for songs",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -463,8 +422,6 @@ class CliConfig:
option(
"--fetch-extra-tags",
help="Fetch extra tags from preview (normalization and smooth playback)",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -478,13 +435,22 @@ class CliConfig:
type=Csv(MusicVideoCodec),
),
]
music_video_remux_mode: Annotated[
RemuxMode,
option(
"--music-video-remux-mode",
help="Remux mode",
default=music_video_downloader_sig.parameters["remux_mode"].default,
type=RemuxMode,
),
]
music_video_remux_format: Annotated[
RemuxFormatMusicVideo,
option(
"--music-video-remux-format",
help="Music video remux format",
default=music_video_downloader_sig.parameters["remux_format"].default,
type=FuncParamType(RemuxFormatMusicVideo),
type=RemuxFormatMusicVideo,
),
]
music_video_resolution: Annotated[
@@ -493,7 +459,7 @@ class CliConfig:
"--music-video-resolution",
help="Max music video resolution",
default=music_video_downloader_sig.parameters["resolution"].default,
type=FuncParamType(MusicVideoResolution),
type=MusicVideoResolution,
),
]
# DownloaderUploadedVideo specific options
@@ -503,7 +469,7 @@ class CliConfig:
"--uploaded-video-quality",
help="Post video quality",
default=uploaded_video_downloader_sig.parameters["quality"].default,
type=FuncParamType(UploadedVideoQuality),
type=UploadedVideoQuality,
),
]
no_config_file: Annotated[
@@ -512,8 +478,6 @@ class CliConfig:
"--no-config-file",
"-n",
help="Don't use a config file",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
+64 -71
View File
@@ -1,25 +1,16 @@
import configparser
import typing
from dataclasses import dataclass
from functools import wraps
from pathlib import Path
from typing import get_type_hints
import click
import click.types as click_types
from dataclass_click.dataclass_click import _DelayedCall
from .cli_config import CliConfig
from .constants import EXCLUDED_CONFIG_FILE_PARAMS
from .utils import Csv
@dataclass
class ParameterInfo:
name: str
default: typing.Any
type: typing.Any
class ConfigFile:
def __init__(
self,
@@ -28,34 +19,10 @@ class ConfigFile:
) -> None:
self.config_path = config_path
self.section_name = section_name
self.parameters = self._extract_parameters_from_cli_config()
self.click_context = click.get_current_context()
self._read_config_file()
def _extract_parameters_from_cli_config(self) -> dict[str, ParameterInfo]:
parameters = {}
hints = get_type_hints(CliConfig, include_extras=True)
for field_name, hint in hints.items():
if hasattr(hint, "__metadata__"):
for metadata in hint.__metadata__:
if isinstance(metadata, _DelayedCall):
param_type = metadata.kwargs.get("type")
if param_type is None:
raise ValueError(
f"Parameter type for field '{field_name}' "
"could not be determined."
)
parameters[field_name] = ParameterInfo(
name=field_name,
default=metadata.kwargs.get("default"),
type=param_type,
)
break
return parameters
def _read_config_file(self) -> None:
self.config = configparser.ConfigParser(interpolation=None)
@@ -71,81 +38,81 @@ class ConfigFile:
with open(self.config_path, "w", encoding="utf-8") as config_file:
self.config.write(config_file)
def _serialize_param_default(self, param_info: ParameterInfo) -> str:
if param_info.default is None:
def _serialize_param_default(self, param: click.Parameter) -> str:
if param.default is None:
return "null"
if isinstance(param_info.type, Csv):
if isinstance(param.type, Csv):
return ",".join(
item.value if hasattr(item, "value") else str(item)
for item in param_info.default
for item in param.default
)
if isinstance(param_info.type, click_types.FuncParamType):
return param_info.default.value
if isinstance(param.type, click_types.FuncParamType):
return param.default.value
if isinstance(param_info.type, click_types.BoolParamType):
return "true" if param_info.default else "false"
if isinstance(param.type, click_types.BoolParamType):
return "true" if param.default else "false"
if isinstance(
param_info.type,
param.type,
click_types.Choice
| click_types.Path
| click_types.StringParamType
| click_types.IntParamType,
):
return str(param_info.default)
return str(param.default)
raise NotImplementedError(
f"Serialization for parameter '{param_info.name}' of type "
f"'{type(param_info.type)}' is not implemented."
f"Serialization for parameter '{param.name}' of type "
f"'{type(param.type)}' is not implemented."
)
def _add_param_default_to_config(
self,
param_info: ParameterInfo,
param: click.Parameter,
) -> bool:
if self.config.has_option(self.section_name, param_info.name):
if self.config.has_option(self.section_name, param.name):
return False
value = self._serialize_param_default(param_info)
self.config.set(self.section_name, param_info.name, value)
value = self._serialize_param_default(param)
self.config.set(self.section_name, param.name, value)
return True
def _parse_param_from_config(
self,
param_info: ParameterInfo,
param: click.Parameter,
) -> typing.Any:
value = self.config[self.section_name].get(param_info.name)
value = self.config[self.section_name].get(param.name)
if value is None:
return param_info.default
return param.default
if value == "null":
return None
if not isinstance(param_info.type, click_types.ParamType):
if not isinstance(param.type, click_types.ParamType):
raise NotImplementedError(
f"Parsing for parameter '{param_info.name}' of type "
f"'{type(param_info.type)}' is not implemented."
f"Parsing for parameter '{param.name}' of type "
f"'{type(param.type)}' is not implemented."
)
return param_info.type.convert(value, None, None)
return param.type.convert(value, None, None)
def add_params_default_to_config(self) -> None:
has_changes = False
for param_info in self.parameters.values():
if param_info.name in EXCLUDED_CONFIG_FILE_PARAMS:
for param in self.click_context.command.params:
if param.name in EXCLUDED_CONFIG_FILE_PARAMS:
continue
has_changes = self._add_param_default_to_config(param_info) or has_changes
has_changes = self._add_param_default_to_config(param) or has_changes
if has_changes:
self._write_config_file()
def cleanup_unknown_params(self) -> None:
param_names = {info.name for info in self.parameters.values()}
param_names = {info.name for info in self.click_context.command.params}
has_changes = False
for key in list(self.config[self.section_name].keys()):
@@ -156,19 +123,45 @@ class ConfigFile:
if has_changes:
self._write_config_file()
def update_params_from_config(self, config: CliConfig) -> CliConfig:
updates = {}
click_context = click.get_current_context()
for param_info in self.parameters.values():
def update_params_from_config(self) -> None:
for param in self.click_context.command.params:
if (
click_context.get_parameter_source(param_info.name)
self.click_context.get_parameter_source(param.name)
== click.core.ParameterSource.COMMANDLINE
):
continue
if self.config.has_option(self.section_name, param_info.name):
updates[param_info.name] = self._parse_param_from_config(param_info)
if self.config.has_option(self.section_name, param.name):
self.click_context.params[param.name] = self._parse_param_from_config(
param
)
config_dict = config.__dict__.copy()
config_dict.update(updates)
def get_cli_config(self) -> CliConfig:
config_dict = {}
for param in self.click_context.command.params:
if param.name in {"help", "version"}:
continue
config_dict[param.name] = self.click_context.params.get(
param.name, param.default
)
return CliConfig(**config_dict)
def load(self) -> CliConfig:
self.cleanup_unknown_params()
self.add_params_default_to_config()
self.update_params_from_config()
return self.get_cli_config()
@staticmethod
def loader(func):
@wraps(func)
def wrapper(cli_config: CliConfig):
ctx = click.get_current_context()
config_path = ctx.params.get("config_path")
no_config_file = ctx.params.get("no_config_file")
if config_path and not no_config_file:
cli_config = ConfigFile(config_path).load()
return func(cli_config)
return wrapper
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -1,6 +1,5 @@
import re
DEFAULT_SONG_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
TEMP_PATH_TEMPLATE = "gamdl_temp_{}"
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
ILLEGAL_CHAR_REPLACEMENT = "_"
@@ -12,8 +11,27 @@ ARTIST_MEDIA_TYPE = {"artist", "artists", "library-artists"}
UPLOADED_VIDEO_MEDIA_TYPE = {"post", "uploaded-videos"}
PLAYLIST_MEDIA_TYPE = {"playlist", "playlists", "library-playlists"}
ARTIST_AUTO_SELECT_KEY_MAP = {
"main-albums": ("views", "full-albums"),
"compilation-albums": ("views", "compilation-albums"),
"live-albums": ("views", "live-albums"),
"singles-eps": ("views", "singles"),
"all-albums": ("relationships", "albums"),
"top-songs": ("views", "top-songs"),
"music-videos": ("relationships", "music-videos"),
}
ARTIST_AUTO_SELECT_STR_MAP = {
"main-albums": "Main Albums",
"compilation-albums": "Compilation Albums",
"live-albums": "Live Albums",
"singles-eps": "Singles & EPs",
"all-albums": "All Albums",
"top-songs": "Top Songs",
"music-videos": "Music Videos",
}
VALID_URL_PATTERN = re.compile(
r"https://music\.apple\.com"
r"https://(?:classical\.)?music\.apple\.com"
r"(?:"
r"/(?P<storefront>[a-z]{2})"
r"/(?P<type>artist|album|playlist|song|music-video|post)"
+216 -130
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from ..api.exceptions import ApiError
from ..interface import AppleMusicInterface
from ..utils import safe_gather
from .constants import (
@@ -20,7 +21,7 @@ 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 .enums import ArtistAutoSelect, DownloadMode, RemuxMode
from .exceptions import (
ExecutableNotFound,
FormatNotAvailable,
@@ -40,6 +41,7 @@ class AppleMusicDownloader:
song_downloader: AppleMusicSongDownloader,
music_video_downloader: AppleMusicMusicVideoDownloader,
uploaded_video_downloader: AppleMusicUploadedVideoDownloader,
artist_auto_select: ArtistAutoSelect | None = None,
skip_music_videos: bool = False,
skip_processing: bool = False,
flat_filter: typing.Callable = None,
@@ -49,6 +51,7 @@ class AppleMusicDownloader:
self.song_downloader = song_downloader
self.music_video_downloader = music_video_downloader
self.uploaded_video_downloader = uploaded_video_downloader
self.artist_auto_select = artist_auto_select
self.skip_music_videos = skip_music_videos
self.skip_processing = skip_processing
self.flat_filter = flat_filter
@@ -149,67 +152,86 @@ class AppleMusicDownloader:
self,
artist_metadata: dict,
) -> list[DownloadItem]:
for relationship in artist_metadata["relationships"].keys():
artist_metadata["relationships"][relationship]["data"].extend(
[
extended_data
async for extended_data in self.interface.apple_music_api.extend_api_data(
artist_metadata["relationships"][relationship],
)
]
)
if not self.artist_auto_select:
available_choices = []
for artist_auto_select_option in list(ArtistAutoSelect):
relation_key, type_key = artist_auto_select_option.path_key
available_choices.append(
Choice(
name=str(artist_auto_select_option),
value=(artist_auto_select_option,),
),
)
media_type = await inquirer.select(
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
choices=[
Choice(
name="Albums",
value="albums",
),
Choice(
name="Music Videos",
value="music-videos",
),
],
validate=lambda result: artist_metadata["relationships"]
.get(result, {})
.get("data"),
invalid_message="The artist doesn't have any items of this type",
).execute_async()
(artist_auto_select,) = await inquirer.select(
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
choices=available_choices,
validate=lambda result: artist_metadata.get(result[0].path_key[0], {})
.get(result[0].path_key[1], {})
.get("data"),
).execute_async()
else:
artist_auto_select = self.artist_auto_select
if media_type == "albums":
relation_key, type_key = artist_auto_select.path_key
async for extended_data in self.interface.apple_music_api.extend_api_data(
artist_metadata[relation_key][type_key],
):
artist_metadata[relation_key][type_key]["data"].extend(extended_data["data"])
selected_items = artist_metadata[relation_key][type_key]["data"]
select_all = self.artist_auto_select is not None
if artist_auto_select in {
ArtistAutoSelect.MAIN_ALBUMS,
ArtistAutoSelect.COMPILATION_ALBUMS,
ArtistAutoSelect.LIVE_ALBUMS,
ArtistAutoSelect.SINGLES_EPS,
ArtistAutoSelect.ALL_ALBUMS,
}:
return await self.get_artist_albums_download_items(
artist_metadata["relationships"]["albums"]["data"]
selected_items,
select_all,
)
if media_type == "music-videos":
elif artist_auto_select == ArtistAutoSelect.TOP_SONGS:
return await self.get_artist_songs_download_items(
selected_items,
select_all,
)
elif artist_auto_select == ArtistAutoSelect.MUSIC_VIDEOS:
return await self.get_artist_music_videos_download_items(
artist_metadata["relationships"]["music-videos"]["data"]
selected_items,
select_all,
)
async def get_artist_albums_download_items(
self,
albums_metadata: list[dict],
select_all: bool = False,
) -> list[DownloadItem]:
choices = [
Choice(
name=" | ".join(
[
f'{album["attributes"]["trackCount"]:03d}',
f'{album["attributes"]["releaseDate"]:<10}',
f'{album["attributes"].get("contentRating", "None").title():<8}',
f'{album["attributes"]["name"]}',
]
),
value=album,
)
for album in albums_metadata
if album.get("attributes")
]
selected = await inquirer.select(
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
if not select_all:
choices = [
Choice(
name=" | ".join(
[
f'{album["attributes"]["trackCount"]:03d}',
f'{album["attributes"]["releaseDate"]:<10}',
f'{album["attributes"].get("contentRating", "None").title():<8}',
f'{album["attributes"]["name"]}',
]
),
value=album,
)
for album in albums_metadata
if album.get("attributes")
]
selected = await inquirer.select(
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
else:
selected = albums_metadata
download_items = []
@@ -233,28 +255,32 @@ class AppleMusicDownloader:
async def get_artist_music_videos_download_items(
self,
music_videos_metadata: list[dict],
select_all: bool = False,
) -> list[DownloadItem]:
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(
music_video["attributes"]["durationInMillis"]
),
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
music_video["attributes"]["name"],
],
),
value=music_video,
)
for music_video in music_videos_metadata
if music_video.get("attributes")
]
selected = await inquirer.select(
message="Select which music videos to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
if not select_all:
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(
music_video["attributes"]["durationInMillis"]
),
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
music_video["attributes"]["name"],
],
),
value=music_video,
)
for music_video in music_videos_metadata
if music_video.get("attributes")
]
selected = await inquirer.select(
message="Select which music videos to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
else:
selected = music_videos_metadata
music_video_tasks = [
self.get_single_download_item(
@@ -266,6 +292,46 @@ class AppleMusicDownloader:
return download_items
async def get_artist_songs_download_items(
self,
songs_metadata: list[dict],
select_all: bool = False,
) -> list[DownloadItem]:
if not select_all:
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(
song["attributes"]["durationInMillis"]
),
f'{song["attributes"].get("contentRating", "None").title():<8}',
song["attributes"]["name"],
],
),
value=song,
)
for song in songs_metadata
if song.get("attributes")
]
selected = await inquirer.select(
message="Select which songs to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
else:
selected = songs_metadata
song_tasks = [
self.get_single_download_item(
song_metadata,
)
for song_metadata in selected
]
download_items = await safe_gather(*song_tasks)
return download_items
def millis_to_min_sec(self, millis) -> str:
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02}:{seconds:02}"
@@ -298,9 +364,14 @@ class AppleMusicDownloader:
download_items = []
if url_type in ARTIST_MEDIA_TYPE:
artist_response = await self.interface.apple_music_api.get_artist(
id,
)
try:
artist_response = await self.interface.apple_music_api.get_artist(
id,
)
except ApiError as e:
if e.status_code == 404:
return None
raise e
if artist_response is None:
return None
@@ -310,7 +381,12 @@ class AppleMusicDownloader:
)
if url_type in SONG_MEDIA_TYPE:
song_respose = await self.interface.apple_music_api.get_song(id)
try:
song_respose = await self.interface.apple_music_api.get_song(id)
except ApiError as e:
if e.status_code == 404:
return None
raise e
if song_respose is None:
return None
@@ -320,12 +396,17 @@ class AppleMusicDownloader:
)
if url_type in ALBUM_MEDIA_TYPE:
if is_library:
album_response = await self.interface.apple_music_api.get_library_album(
id
)
else:
album_response = await self.interface.apple_music_api.get_album(id)
try:
if is_library:
album_response = (
await self.interface.apple_music_api.get_library_album(id)
)
else:
album_response = await self.interface.apple_music_api.get_album(id)
except ApiError as e:
if e.status_code == 404:
return None
raise e
if album_response is None:
return None
@@ -335,14 +416,19 @@ class AppleMusicDownloader:
)
if url_type in PLAYLIST_MEDIA_TYPE:
if is_library:
playlist_response = (
await self.interface.apple_music_api.get_library_playlist(id)
)
else:
playlist_response = await self.interface.apple_music_api.get_playlist(
id
)
try:
if is_library:
playlist_response = (
await self.interface.apple_music_api.get_library_playlist(id)
)
else:
playlist_response = (
await self.interface.apple_music_api.get_playlist(id)
)
except ApiError as e:
if e.status_code == 404:
return None
raise e
if playlist_response is None:
return None
@@ -352,9 +438,14 @@ class AppleMusicDownloader:
)
if url_type in MUSIC_VIDEO_MEDIA_TYPE:
music_video_response = await self.interface.apple_music_api.get_music_video(
id
)
try:
music_video_response = (
await self.interface.apple_music_api.get_music_video(id)
)
except ApiError as e:
if e.status_code == 404:
return None
raise e
if music_video_response is None:
return None
@@ -364,7 +455,14 @@ class AppleMusicDownloader:
)
if url_type in UPLOADED_VIDEO_MEDIA_TYPE:
uploaded_video = await self.interface.apple_music_api.get_uploaded_video(id)
try:
uploaded_video = (
await self.interface.apple_music_api.get_uploaded_video(id)
)
except ApiError as e:
if e.status_code == 404:
return None
raise e
if uploaded_video is None:
return None
@@ -417,57 +515,45 @@ class AppleMusicDownloader:
):
raise MediaFileExists(download_item.final_path)
if download_item.media_metadata["type"] in {
*SONG_MEDIA_TYPE,
*MUSIC_VIDEO_MEDIA_TYPE,
}:
if (
self.base_downloader.download_mode == DownloadMode.NM3U8DLRE
and not self.base_downloader.full_nm3u8dlre_path
):
raise ExecutableNotFound("N_m3u8DL-RE")
if download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
if (
self.base_downloader.remux_mode == RemuxMode.FFMPEG
self.music_video_downloader.remux_mode == RemuxMode.FFMPEG
and not self.base_downloader.full_ffmpeg_path
):
raise ExecutableNotFound("ffmpeg")
if (
self.base_downloader.remux_mode == RemuxMode.MP4BOX
self.music_video_downloader.remux_mode == RemuxMode.MP4BOX
and not self.base_downloader.full_mp4box_path
):
raise ExecutableNotFound("MP4Box")
if (
self.song_downloader.use_wrapper
or (
download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
or self.base_downloader.remux_mode == RemuxMode.MP4BOX
)
download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
or self.music_video_downloader.remux_mode == RemuxMode.MP4BOX
) and not self.base_downloader.full_mp4decrypt_path:
raise ExecutableNotFound("mp4decrypt")
if (
self.song_downloader.use_wrapper
and not self.base_downloader.full_amdecrypt_path
):
raise ExecutableNotFound("amdecrypt")
if (
self.base_downloader.download_mode == DownloadMode.NM3U8DLRE
and not self.base_downloader.full_nm3u8dlre_path
):
raise ExecutableNotFound("N_m3u8DL-RE")
if (
not download_item.stream_info
or not download_item.stream_info.audio_track
or not download_item.stream_info.audio_track.stream_url
or (
(
not download_item.decryption_key
or not download_item.decryption_key.audio_track
or not download_item.decryption_key.audio_track.key
)
and not self.base_downloader.use_wrapper
if (
not download_item.stream_info
or not download_item.stream_info.audio_track
or not download_item.stream_info.audio_track.stream_url
or (
(
not download_item.decryption_key
or not download_item.decryption_key.audio_track
or not download_item.decryption_key.audio_track.key
)
):
raise FormatNotAvailable(download_item.media_metadata["id"])
and not self.base_downloader.use_wrapper
)
):
raise FormatNotAvailable(download_item.media_metadata["id"])
if download_item.media_metadata["type"] in SONG_MEDIA_TYPE:
await self.song_downloader.download(download_item)
+1 -6
View File
@@ -12,7 +12,7 @@ from ..interface.enums import CoverFormat
from ..interface.types import MediaTags, PlaylistTags
from ..utils import CustomStringFormatter, async_subprocess
from .constants import ILLEGAL_CHAR_REPLACEMENT, ILLEGAL_CHARS_RE, TEMP_PATH_TEMPLATE
from .enums import DownloadMode, RemuxMode
from .enums import DownloadMode
from .hardcoded_wvd import HARDCODED_WVD
@@ -29,11 +29,9 @@ class AppleMusicBaseDownloader:
mp4decrypt_path: str = "mp4decrypt",
ffmpeg_path: str = "ffmpeg",
mp4box_path: str = "MP4Box",
amdecrypt_path: str = "amdecrypt",
use_wrapper: bool = False,
wrapper_decrypt_ip: str = "127.0.0.1:10020",
download_mode: DownloadMode = DownloadMode.YTDLP,
remux_mode: RemuxMode = RemuxMode.FFMPEG,
cover_format: CoverFormat = CoverFormat.JPG,
album_folder_template: str = "{album_artist}/{album}",
compilation_folder_template: str = "Compilations/{album}",
@@ -58,11 +56,9 @@ class AppleMusicBaseDownloader:
self.mp4decrypt_path = mp4decrypt_path
self.ffmpeg_path = ffmpeg_path
self.mp4box_path = mp4box_path
self.amdecrypt_path = amdecrypt_path
self.use_wrapper = use_wrapper
self.wrapper_decrypt_ip = wrapper_decrypt_ip
self.download_mode = download_mode
self.remux_mode = remux_mode
self.cover_format = cover_format
self.album_folder_template = album_folder_template
self.compilation_folder_template = compilation_folder_template
@@ -87,7 +83,6 @@ class AppleMusicBaseDownloader:
self.full_mp4decrypt_path = shutil.which(self.mp4decrypt_path)
self.full_ffmpeg_path = shutil.which(self.ffmpeg_path)
self.full_mp4box_path = shutil.which(self.mp4box_path)
self.full_amdecrypt_path = shutil.which(self.amdecrypt_path)
def _initialize_cdm(self):
if self.wvd_path:
+8 -2
View File
@@ -1,6 +1,6 @@
from pathlib import Path
from ..interface.enums import MusicVideoCodec, MusicVideoResolution
from ..interface.enums import CoverFormat, MusicVideoCodec, MusicVideoResolution
from ..interface.interface_music_video import AppleMusicMusicVideoInterface
from ..interface.types import DecryptionKeyAv
from ..utils import async_subprocess
@@ -18,12 +18,14 @@ class AppleMusicMusicVideoDownloader(AppleMusicBaseDownloader):
MusicVideoCodec.H264,
MusicVideoCodec.H265,
],
remux_mode: RemuxMode = RemuxMode.FFMPEG,
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
):
self.__dict__.update(base_downloader.__dict__)
self.interface = interface
self.codec_priority = codec_priority
self.remux_mode = remux_mode
self.remux_format = remux_format
self.resolution = resolution
@@ -271,7 +273,11 @@ class AppleMusicMusicVideoDownloader(AppleMusicBaseDownloader):
download_item.decryption_key,
)
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
cover_bytes = (
await self.interface.get_cover_bytes(download_item.cover_url)
if self.cover_format != CoverFormat.RAW
else None
)
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
+50 -149
View File
@@ -1,12 +1,10 @@
from pathlib import Path
from ..interface.enums import SongCodec, SyncedLyricsFormat
from ..interface.enums import CoverFormat, SongCodec, SyncedLyricsFormat
from ..interface.interface_song import AppleMusicSongInterface
from ..interface.types import DecryptionKeyAv
from ..utils import async_subprocess
from .constants import DEFAULT_SONG_DECRYPTION_KEY
from .amdecrypt import decrypt_file, decrypt_file_hex
from .downloader_base import AppleMusicBaseDownloader
from .enums import RemuxMode
from .types import DownloadItem
@@ -15,7 +13,7 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
self,
base_downloader: AppleMusicBaseDownloader,
interface: AppleMusicSongInterface,
codec: SongCodec = SongCodec.AAC_LEGACY,
codec_priority: SongCodec = [SongCodec.AAC_LEGACY],
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
no_synced_lyrics: bool = False,
synced_lyrics_only: bool = False,
@@ -24,7 +22,7 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
):
self.__dict__.update(base_downloader.__dict__)
self.interface = interface
self.codec = codec
self.codec_priority = codec_priority
self.synced_lyrics_format = synced_lyrics_format
self.no_synced_lyrics = no_synced_lyrics
self.synced_lyrics_only = synced_lyrics_only
@@ -80,33 +78,31 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
if self.synced_lyrics_only:
return download_item
if self.codec.is_legacy():
download_item.stream_info = await self.interface.get_stream_info_legacy(
for codec in self.codec_priority:
download_item.stream_info = await self.interface.get_stream_info(
codec,
song_metadata,
webplayback,
self.codec,
)
if download_item.stream_info:
break
if download_item.stream_info.audio_track.legacy:
download_item.decryption_key = (
await self.interface.get_decryption_key_legacy(
download_item.stream_info,
self.cdm,
)
)
else:
download_item.stream_info = await self.interface.get_stream_info(
song_metadata,
self.codec,
elif (
not self.use_wrapper
and download_item.stream_info
and download_item.stream_info.audio_track.widevine_pssh
):
download_item.decryption_key = await self.interface.get_decryption_key(
download_item.stream_info,
self.cdm,
)
if (
not self.use_wrapper
and download_item.stream_info
and download_item.stream_info.audio_track.widevine_pssh
):
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.interface.get_cover_url_template(
song_metadata,
@@ -141,93 +137,6 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
return download_item
def fix_key_id(self, input_path: str):
count = 0
with open(input_path, "rb+") as file:
while data := file.read(4096):
pos = file.tell()
i = 0
while tenc := max(0, data.find(b"tenc", i)):
kid = tenc + 12
file.seek(max(0, pos - 4096) + kid, 0)
file.write(bytes.fromhex(f"{count:032}"))
count += 1
i = kid + 1
file.seek(pos, 0)
async def remux_mp4box(self, input_path: str, output_path: str):
await async_subprocess(
self.full_mp4box_path,
"-quiet",
"-add",
input_path,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
output_path,
silent=self.silent,
)
async def remux_ffmpeg(
self,
input_path: str,
output_path: str,
decryption_key: str = None,
):
if decryption_key:
key = [
"-decryption_key",
decryption_key,
]
else:
key = []
await async_subprocess(
self.full_ffmpeg_path,
"-loglevel",
"error",
"-y",
*key,
"-i",
input_path,
"-c",
"copy",
"-movflags",
"+faststart",
output_path,
silent=self.silent,
)
async def decrypt_mp4decrypt(
self,
input_path: str,
output_path: str,
decryption_key: str,
legacy: bool,
):
if legacy:
keys = [
"--key",
f"1:{decryption_key}",
]
else:
self.fix_key_id(input_path)
keys = [
"--key",
"0" * 31 + "1" + f":{decryption_key}",
"--key",
"0" * 32 + f":{DEFAULT_SONG_DECRYPTION_KEY}",
]
await async_subprocess(
self.full_mp4decrypt_path,
*keys,
input_path,
output_path,
silent=self.silent,
)
async def decrypt_amdecrypt(
self,
input_path: str,
@@ -235,56 +144,51 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
media_id: str,
fairplay_key: str,
) -> None:
await async_subprocess(
self.amdecrypt_path,
await decrypt_file(
self.wrapper_decrypt_ip,
self.full_mp4decrypt_path,
media_id,
fairplay_key,
input_path,
output_path,
)
async def decrypt_amdecrypt_hex(
self,
input_path: str,
output_path: str,
decryption_key: str,
legacy: bool = False,
) -> None:
await decrypt_file_hex(
input_path,
output_path,
decryption_key,
legacy=legacy,
)
async def stage(
self,
encrypted_path: str,
decrypted_path: str,
staged_path: str,
decryption_key: DecryptionKeyAv,
codec: SongCodec,
legacy: bool,
media_id: str,
fairplay_key: str,
):
if codec.is_legacy() and self.remux_mode == RemuxMode.FFMPEG:
await self.remux_ffmpeg(
encrypted_path,
staged_path,
decryption_key.audio_track.key,
)
elif codec.is_legacy() or not self.use_wrapper:
await self.decrypt_mp4decrypt(
encrypted_path,
decrypted_path,
decryption_key.audio_track.key,
codec.is_legacy(),
)
if self.remux_mode == RemuxMode.FFMPEG:
await self.remux_ffmpeg(
decrypted_path,
staged_path,
)
else:
await self.remux_mp4box(
decrypted_path,
staged_path,
)
else:
if self.use_wrapper and not legacy:
await self.decrypt_amdecrypt(
encrypted_path,
staged_path,
media_id,
fairplay_key,
)
else:
await self.decrypt_amdecrypt_hex(
encrypted_path,
staged_path,
decryption_key.audio_track.key,
legacy,
)
def get_lyrics_synced_path(self, final_path: str) -> str:
return str(Path(final_path).with_suffix("." + self.synced_lyrics_format.value))
@@ -322,23 +226,20 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
encrypted_path,
)
decrypted_path = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"decrypted",
".m4a",
)
await self.stage(
encrypted_path,
decrypted_path,
download_item.staged_path,
download_item.decryption_key,
self.codec,
download_item.stream_info.audio_track.legacy,
download_item.media_metadata["id"],
download_item.stream_info.audio_track.fairplay_key,
)
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
cover_bytes = (
await self.interface.get_cover_bytes(download_item.cover_url)
if self.cover_format != CoverFormat.RAW
else None
)
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
@@ -1,6 +1,6 @@
from pathlib import Path
from ..interface.enums import UploadedVideoQuality
from ..interface.enums import CoverFormat, UploadedVideoQuality
from ..interface.interface_uploaded_video import AppleMusicUploadedVideoInterface
from .downloader_base import AppleMusicBaseDownloader
from .types import DownloadItem
@@ -95,7 +95,11 @@ class AppleMusicUploadedVideoDownloader(AppleMusicBaseDownloader):
download_item.staged_path,
)
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
cover_bytes = (
await self.interface.get_cover_bytes(download_item.cover_url)
if self.cover_format != CoverFormat.RAW
else None
)
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
+22
View File
@@ -1,5 +1,10 @@
from enum import Enum
from .constants import (
ARTIST_AUTO_SELECT_KEY_MAP,
ARTIST_AUTO_SELECT_STR_MAP,
)
class DownloadMode(Enum):
YTDLP = "ytdlp"
@@ -14,3 +19,20 @@ class RemuxMode(Enum):
class RemuxFormatMusicVideo(Enum):
M4V = "m4v"
MP4 = "mp4"
class ArtistAutoSelect(Enum):
MAIN_ALBUMS = "main-albums"
COMPILATION_ALBUMS = "compilation-albums"
LIVE_ALBUMS = "live-albums"
SINGLES_EPS = "singles-eps"
ALL_ALBUMS = "all-albums"
TOP_SONGS = "top-songs"
MUSIC_VIDEOS = "music-videos"
@property
def path_key(self) -> tuple[str, str]:
return ARTIST_AUTO_SELECT_KEY_MAP[self.value]
def __str__(self) -> str:
return ARTIST_AUTO_SELECT_STR_MAP[self.value]
+1 -2
View File
@@ -1,5 +1,4 @@
class GamdlError(Exception):
pass
from ..utils import GamdlError
class MediaFileExists(GamdlError):
+5 -5
View File
@@ -79,7 +79,8 @@ class AppleMusicInterface:
cover_url_template = self._get_raw_cover_url(
metadata["attributes"]["artwork"]["url"]
)
cover_url_template = metadata["attributes"]["artwork"]["url"]
else:
cover_url_template = metadata["attributes"]["artwork"]["url"]
logger.debug(f"Cover URL template: {cover_url_template}")
return cover_url_template
@@ -102,9 +103,9 @@ class AppleMusicInterface:
cover_format: CoverFormat,
) -> str:
cover_url = re.sub(
r"\{w\}x\{h\}([a-z]{2})\.jpg",
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
(
f"{cover_size}x{cover_size}bb.{cover_format.value}"
f"/{cover_size}x{cover_size}bb.{cover_format.value}"
if cover_format != CoverFormat.RAW
else ""
),
@@ -123,12 +124,11 @@ class AppleMusicInterface:
if cover_format != CoverFormat.RAW:
return f".{cover_format.value}"
cover_url = self.get_cover_url(cover_url)
cover_bytes = await self.get_cover_bytes(cover_url)
if cover_bytes is None:
return None
image_obj = Image.open(BytesIO(self.get_cover_bytes(cover_url)))
image_obj = Image.open(BytesIO(await self.get_cover_bytes(cover_url)))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(
image_format,
+21 -3
View File
@@ -226,10 +226,28 @@ class AppleMusicSongInterface(AppleMusicInterface):
return tags
async def get_stream_info(
self,
codec: SongCodec,
song_metadata: dict | None = None,
webplayback: dict | None = None,
) -> StreamInfoAv | None:
if codec.is_legacy():
return await self._get_stream_info_legacy(webplayback, codec)
else:
return await self._get_stream_info(song_metadata, codec)
async def _get_stream_info(
self,
song_metadata: dict,
codec: SongCodec,
) -> StreamInfoAv | None:
if "extendedAssetUrls" not in song_metadata["attributes"]:
song_metadata = (
await self.apple_music_api.get_song(
self.get_media_id_of_library_media(song_metadata),
)
)["data"][0]
m3u8_master_url = song_metadata["attributes"]["extendedAssetUrls"].get(
"enhancedHls"
)
@@ -250,7 +268,7 @@ class AppleMusicSongInterface(AppleMusicInterface):
if playlist is None:
return None
stream_info = StreamInfo()
stream_info = StreamInfo(legacy=False)
stream_info.stream_url = (
f"{m3u8_master_url.rpartition('/')[0]}/{playlist['uri']}"
)
@@ -379,14 +397,14 @@ class AppleMusicSongInterface(AppleMusicInterface):
return key.uri
return None
async def get_stream_info_legacy(
async def _get_stream_info_legacy(
self,
webplayback: dict,
codec: SongCodec,
) -> StreamInfoAv:
flavor = "32:ctrp64" if codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
stream_info = StreamInfo()
stream_info = StreamInfo(legacy=True)
stream_info.stream_url = next(
i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor
)["URL"]
+1
View File
@@ -121,6 +121,7 @@ class StreamInfo:
codec: str = None
width: int = None
height: int = None
legacy: bool = None
@dataclass
+6 -2
View File
@@ -14,11 +14,11 @@ def raise_for_status(httpx_response: httpx.Response, valid_responses: set[int] =
)
def safe_json(httpx_response: httpx.Response) -> dict:
def safe_json(httpx_response: httpx.Response) -> dict | None:
try:
return httpx_response.json()
except (json.JSONDecodeError, UnicodeDecodeError):
return {}
return None
async def get_response(
@@ -95,3 +95,7 @@ class CustomStringFormatter(string.Formatter):
return fallback_value
return super().format_field(value, format_spec)
class GamdlError(Exception):
pass
+1 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "gamdl"
version = "2.8.3"
version = "2.9.2"
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
readme = "README.md"
license = "MIT"
@@ -24,6 +24,3 @@ Repository = "https://github.com/glomatico/gamdl"
[project.scripts]
gamdl = "gamdl.cli.cli:main"
[tool.setuptools]
packages = ["gamdl"]
Generated
+1 -1
View File
@@ -214,7 +214,7 @@ wheels = [
[[package]]
name = "gamdl"
version = "2.8.3"
version = "2.9.2"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },