Compare commits

...

49 Commits

Author SHA1 Message Date
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
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
22 changed files with 2320 additions and 618 deletions
+61 -69
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
@@ -109,62 +107,62 @@ 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** | | |
| `--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` | 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` |
| `--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 +253,9 @@ 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
## ⚙️ Wrapper
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.
### Prerequisites
- **[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"
+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
+47 -38
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,
@@ -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,
)
@@ -169,27 +171,6 @@ async def main(config: CliConfig):
)
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 +178,42 @@ 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 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 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(
"Music videos will not be downloaded due to missing dependencies:\n"
+ "\n".join(missing_music_video_paths)
)
if not config.song_codec.is_legacy() and not config.use_wrapper:
logger.warning(
"You have chosen an experimental song codec"
" without enabling wrapper."
"You have chosen an experimental song codec "
"without enabling wrapper. "
"They're not guaranteed to work due to API limitations."
)
+17 -66
View File
@@ -4,7 +4,6 @@ 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
@@ -55,8 +54,6 @@ class CliConfig:
"--read-urls-as-txt",
"-r",
help="Read URLs from text files",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -102,8 +99,6 @@ class CliConfig:
option(
"--no-exceptions",
help="Don't print exceptions",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -129,7 +124,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 +133,6 @@ class CliConfig:
"-l",
help="Metadata language",
default=api_sig.parameters["language"].default,
type=StringParamType(),
),
]
# Base Downloader specific options
@@ -191,8 +184,6 @@ class CliConfig:
option(
"--overwrite",
help="Overwrite existing files",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -202,8 +193,6 @@ class CliConfig:
"--save-cover",
"-s",
help="Save cover as separate file",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -212,8 +201,6 @@ class CliConfig:
option(
"--save-playlist",
help="Save M3U8 playlist file",
type=BoolParamType(),
default=False,
is_flag=True,
),
]
@@ -223,7 +210,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 +218,6 @@ class CliConfig:
"--mp4decrypt-path",
help="mp4decrypt executable path",
default=base_downloader_sig.parameters["mp4decrypt_path"].default,
type=StringParamType(),
),
]
ffmpeg_path: Annotated[
@@ -241,7 +226,6 @@ class CliConfig:
"--ffmpeg-path",
help="FFmpeg executable path",
default=base_downloader_sig.parameters["ffmpeg_path"].default,
type=StringParamType(),
),
]
mp4box_path: Annotated[
@@ -250,25 +234,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 +250,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 +258,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 +267,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 +276,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 +286,6 @@ class CliConfig:
default=base_downloader_sig.parameters[
"compilation_folder_template"
].default,
type=StringParamType(),
),
]
no_album_folder_template: Annotated[
@@ -334,7 +294,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 +302,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 +310,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 +318,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 +326,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 +334,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 +351,6 @@ class CliConfig:
"--cover-size",
help="Cover size in pixels",
default=base_downloader_sig.parameters["cover_size"].default,
type=IntParamType(),
),
]
truncate: Annotated[
@@ -406,7 +359,6 @@ class CliConfig:
"--truncate",
help="Max filename length",
default=base_downloader_sig.parameters["truncate"].default,
type=IntParamType(),
),
]
# DownloaderSong specific options
@@ -416,7 +368,7 @@ class CliConfig:
"--song-codec",
help="Song codec",
default=song_downloader_sig.parameters["codec"].default,
type=FuncParamType(SongCodec),
type=SongCodec,
),
]
synced_lyrics_format: Annotated[
@@ -425,7 +377,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 +385,6 @@ class CliConfig:
option(
"--no-synced-lyrics",
help="Don't download synced lyrics",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -443,8 +393,6 @@ class CliConfig:
option(
"--synced-lyrics-only",
help="Download only synced lyrics",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -453,8 +401,6 @@ class CliConfig:
option(
"--use-album-date",
help="Use album release date for songs",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -463,8 +409,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 +422,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 +446,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 +456,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 +465,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
-1
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 = "_"
+161 -81
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 (
@@ -149,42 +150,67 @@ 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],
)
]
)
media_type = await inquirer.select(
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
choices=[
Choice(
name="Albums",
value="albums",
name="Main Albums",
value=["views", "full-albums"],
),
Choice(
name="Compilations Albums",
value=["views", "compilation-albums"],
),
Choice(
name="Live Albums",
value=["views", "live-albums"],
),
Choice(
name="Singles & EPs",
value=["views", "singles"],
),
Choice(
name="All Albums",
value=["relationships", "albums"],
),
Choice(
name="Top Songs",
value=["views", "top-songs"],
),
Choice(
name="Music Videos",
value="music-videos",
value=["relationships", "music-videos"],
),
],
validate=lambda result: artist_metadata["relationships"]
.get(result, {})
validate=lambda result: artist_metadata.get(result[0], {})
.get(result[1], {})
.get("data"),
invalid_message="The artist doesn't have any items of this type",
).execute_async()
if media_type == "albums":
return await self.get_artist_albums_download_items(
artist_metadata["relationships"]["albums"]["data"]
)
if media_type == "music-videos":
return await self.get_artist_music_videos_download_items(
artist_metadata["relationships"]["music-videos"]["data"]
)
media_type, media_type_key = media_type
artist_metadata[media_type][media_type_key]["data"].extend(
[
extended_data
async for extended_data in self.interface.apple_music_api.extend_api_data(
artist_metadata[media_type][media_type_key],
)
]
)
selected_tracks = artist_metadata[media_type][media_type_key]["data"]
if media_type_key in {
"full-albums",
"compilation-albums",
"live-albums",
"singles",
"albums",
}:
return await self.get_artist_albums_download_items(selected_tracks)
elif media_type_key == "top-songs":
return await self.get_artist_songs_download_items(selected_tracks)
elif media_type_key == "music-videos":
return await self.get_artist_music_videos_download_items(selected_tracks)
async def get_artist_albums_download_items(
self,
@@ -266,6 +292,40 @@ class AppleMusicDownloader:
return download_items
async def get_artist_songs_download_items(
self,
songs_metadata: list[dict],
) -> list[DownloadItem]:
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()
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 +358,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 +375,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 +390,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 +410,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 +432,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 +449,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 +509,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,
+30 -127
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
@@ -141,93 +139,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 +146,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,
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:
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=codec.is_legacy(),
)
def get_lyrics_synced_path(self, final_path: str) -> str:
return str(Path(final_path).with_suffix("." + self.synced_lyrics_format.value))
@@ -322,15 +228,8 @@ 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,
@@ -338,7 +237,11 @@ class AppleMusicSongDownloader(AppleMusicBaseDownloader):
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,
+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,
+7
View File
@@ -230,6 +230,13 @@ class AppleMusicSongInterface(AppleMusicInterface):
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"
)
+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"
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"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },