mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-14 04:35:23 +03:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29f68f6bc4 | |||
| e77c6b24b4 | |||
| ba315dcb95 | |||
| 4187fad734 | |||
| f36edf4bbd | |||
| 50478d427e | |||
| 45461007a9 | |||
| 79a03d4f4c | |||
| beb508529a | |||
| 87cf8c7789 | |||
| 9e3f740eec | |||
| 7281f5c949 | |||
| d32781b23f | |||
| 5f2c74399e | |||
| 6b67c435fa | |||
| 240ba7d4de | |||
| 02c19963b4 | |||
| 2e2fef1426 | |||
| ae3b2e1c6d | |||
| 6516855be9 | |||
| 77cbb8a7ca | |||
| 18bc6595a9 | |||
| da2c3d5f1e | |||
| abe364aad1 | |||
| 10b529d6fd | |||
| afe42848d0 | |||
| b3b5e6d1b2 | |||
| 9f86c7436d | |||
| 74a26d0342 | |||
| 37895dea1c | |||
| 04396a7f3f | |||
| bde49305c9 | |||
| b0c3b4630d | |||
| fd30ab861b | |||
| b1827e8d1b | |||
| fe020442b1 | |||
| 87b8492b4f | |||
| f961ade8d8 | |||
| 471a2e85ac | |||
| a17b1296d8 | |||
| 22628c4c53 | |||
| 23a5be37b1 | |||
| 9aa7a2e199 | |||
| 31d07172a6 | |||
| fbe0167f0e | |||
| 1d621568a0 | |||
| fa31649d76 | |||
| 16d8dc925a | |||
| 46d1ec11dc | |||
| f68e76ce8b | |||
| 42df1f7f5e | |||
| d11e937c6a | |||
| a7c8ff4297 | |||
| 5332e0e1c0 | |||
| b8ea1d0039 | |||
| 4de0e3d1f8 | |||
| c770ff361f | |||
| d6afb680be | |||
| b15f404849 | |||
| 072d71caaf | |||
| 7e132c27de | |||
| 073f70afa7 | |||
| a49430018a | |||
| f0450b93c7 |
@@ -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
@@ -1 +1 @@
|
||||
__version__ = "2.8.3"
|
||||
__version__ = "2.9.2"
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
from gamdl.cli.cli import main
|
||||
from .cli.cli import main
|
||||
|
||||
main()
|
||||
|
||||
+90
-130
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
class GamdlError(Exception):
|
||||
pass
|
||||
from ..utils import GamdlError
|
||||
|
||||
|
||||
class MediaFileExists(GamdlError):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -121,6 +121,7 @@ class StreamInfo:
|
||||
codec: str = None
|
||||
width: int = None
|
||||
height: int = None
|
||||
legacy: bool = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+6
-2
@@ -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
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user