Compare commits

...

78 Commits

Author SHA1 Message Date
Rafael Moraes ccd51d4dc1 Clarify README note about wrapper login 2026-05-20 17:59:54 -03:00
Rafael Moraes 35b3013b87 Refactor wrapper related methods to WrapperApi 2026-05-20 17:52:06 -03:00
Rafael Moraes 8aeda0abff Note that wrapper can skip cookies 2026-05-20 17:38:02 -03:00
Rafael Moraes 30aeee90b8 Add use_cenc and use_single_content_key to StreamInfo 2026-05-20 16:57:50 -03:00
Rafael Moraes 67bdfe8584 Add song codec flavor mappings and properties 2026-05-20 16:57:34 -03:00
Rafael Moraes 97086adfbe Add CENC and single content key support 2026-05-20 16:57:25 -03:00
Rafael Moraes da7346f704 Add use_single_content_key and use_cenc options 2026-05-20 16:57:15 -03:00
Rafael Moraes 3dd829b38c Bump version 2026-05-20 15:03:22 -03:00
Rafael Moraes c503d482a7 Bump version 2026-05-20 15:03:03 -03:00
Rafael Moraes 46df1672d9 README: add subscription note & update cookie text 2026-05-20 15:02:09 -03:00
Rafael Moraes d61e315362 Remove redundant Optional Dependencies note 2026-05-20 14:58:52 -03:00
Rafael Moraes b787e64820 Clarify README: optional deps, wrapper & codecs 2026-05-20 14:57:36 -03:00
Rafael Moraes 31d6ba7c93 Clarify wrapper CLI option help 2026-05-20 14:57:26 -03:00
Rafael Moraes 4841b953a7 Run yt-dlp in separate process 2026-05-20 14:46:35 -03:00
Rafael Moraes ada986573d Use codec.is_web property in codec check 2026-05-20 14:31:48 -03:00
Rafael Moraes 8ea1373c83 Use use_prefetch_key flag; update song staging 2026-05-20 14:31:37 -03:00
Rafael Moraes b7fdf7356f Support web AAC codecs and web stream handling 2026-05-20 14:31:22 -03:00
Rafael Moraes fba6a72747 Return stream_info in AppleMusicSongInterface 2026-05-20 08:27:10 -03:00
Rafael Moraes 48df71271b Add native music video muxing 2026-05-20 07:57:52 -03:00
Rafael Moraes cbd161038e Add logging to get_wrapper_playback 2026-05-20 06:44:37 -03:00
Rafael Moraes 66c3a0fcf1 Add media_id and raise on missing stream formats 2026-05-20 06:44:19 -03:00
Rafael Moraes b0b13e8367 Decrypt prefetch/default-key samples locally with DEFAULT_SONG_DECRYPTION_KEY 2026-05-18 22:05:20 -03:00
Rafael Moraes 7dab944908 Use /decrypt endpoint and cleanup formatting 2026-05-18 19:38:32 -03:00
Rafael Moraes ffeb3bcfec Adjust default wrapper decrypt endpoint 2026-05-18 18:30:41 -03:00
Rafael Moraes 6aae17c138 Use /sample/decrypt endpoint for decryption 2026-05-18 14:52:33 -03:00
Rafael Moraes 4cdad09372 Refactor amdecrypt for wrapper-v2 /decrypt/samples 2026-05-18 14:52:21 -03:00
Rafael Moraes 86bbb94274 Refactor music video to use tags from asset_info when using wrapper 2026-05-18 14:05:33 -03:00
Rafael Moraes e44b037414 Update to wrapper-v2 endpoints 2026-05-18 13:42:30 -03:00
Rafael Moraes 2205b76c07 Use wrapper-v2 HTTP decrypt for FairPlay CBCS.
Point amdecrypt at POST /decrypt with batched samples, robust moof/trun/senc parsing and CBCS subsample handling, and CLI/base defaults for the daemon URL. Update download/song and Apple Music paths to use the new flow; includes formatting and related API touch-ups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 11:24:20 -03:00
Rafael Moraes 82e3cf20a0 Bump version to 3.5.2 2026-05-13 20:26:05 -03:00
Rafael Moraes bc4cdd181c Open file with UTF-8 encoding in add_file 2026-05-13 20:24:53 -03:00
Rafael Moraes dec4a22208 Bind logger and log m3u8 master URL extraction 2026-05-13 20:24:45 -03:00
Rafael Moraes b48dbeff8e Forward next_params (except limit) for pagination 2026-05-13 07:25:34 -03:00
Rafael Moraes 34a397eb18 Bump gamdl version to 3.5.1 2026-05-07 18:09:20 -03:00
Rafael Moraes 2c3abfd352 Bump version to 3.5.1 2026-05-07 18:08:26 -03:00
Rafael Moraes 1fc708177c Normalize Apple Music m3u8 master URL 2026-05-07 18:01:27 -03:00
Rafael Moraes f670fe8e95 Bump version to 3.5 2026-04-27 09:19:46 -03:00
Rafael Moraes 8f184fcb66 Remove '-28' from X-Apple-Store-Front header 2026-04-27 09:17:36 -03:00
Rafael Moraes 3765ef0df4 Set storefront_id None for non-US iTunes API 2026-04-27 08:56:43 -03:00
Rafael Moraes 4e28b7e9a3 Enable redirects and use correct storefront header 2026-04-27 08:54:22 -03:00
Rafael Moraes a009071a8d Bump version to 3.4 2026-04-27 06:35:39 -03:00
Rafael Moraes 64b1974232 Include filter result in exclusion error message 2026-04-27 06:35:00 -03:00
Rafael Moraes 37ede6572e Add overwrite flag to Database 2026-04-27 06:34:51 -03:00
Rafael Moraes 2e57216c3c Strip size suffix from Apple Music cover URLs 2026-04-27 06:25:55 -03:00
Rafael Moraes 5d242c89cd Remove 'level' and 'event' from event_dict 2026-04-26 11:41:47 -03:00
Rafael Moraes e5675f8874 Use CustomOutputWriter for structlog output 2026-04-26 00:38:08 -03:00
Rafael Moraes 716112c294 Use default_factory for DownloadItem uuid 2026-04-25 14:52:19 -03:00
Rafael Moraes 63ad0f2e07 Respect skip_cleanup when removing temp files 2026-04-25 14:28:09 -03:00
Rafael Moraes 939520b3f8 Stringify subprocess args in error message 2026-04-25 14:02:52 -03:00
Rafael Moraes df23276d3c Improve subprocess error message 2026-04-25 13:56:55 -03:00
Rafael Moraes a9227493ea Include subprocess output in async errors 2026-04-25 13:03:48 -03:00
Rafael Moraes 9375c2fccd Bump version to 3.3 2026-04-24 19:48:58 -03:00
Rafael Moraes c83e47df0c Remove total arg from media fetch calls 2026-04-24 19:48:27 -03:00
Rafael Moraes 715820e357 Bump version to 3.2 2026-04-24 16:17:49 -03:00
Rafael Moraes 137a739af2 Collect async generators for concurrency 2026-04-24 16:05:37 -03:00
Rafael Moraes 23220d1827 Limit download logging and use interface exception 2026-04-24 15:48:14 -03:00
Rafael Moraes 3c7ea272af Skip partial media; Remove flat filter exception 2026-04-24 15:44:40 -03:00
Rafael Moraes 34a92b6efc Refactor interface media fetching 2026-04-24 15:44:19 -03:00
Rafael Moraes 3a907cb76c Remove skip_decryption_key_non_legacy arg 2026-04-24 13:02:22 -03:00
Rafael Moraes 90646e7193 Use base.use_wrapper for decryption checks 2026-04-24 13:02:07 -03:00
Rafael Moraes 3b2875ccd1 Remove use_wrapper parameter and attribute 2026-04-24 12:59:01 -03:00
Rafael Moraes a989d9fefa Include index and total for music-video media fetch 2026-04-24 12:17:19 -03:00
Rafael Moraes fd3b6216c9 Use error() for URL parse errors 2026-04-24 12:08:24 -03:00
Rafael Moraes 84c21c0013 Pass total=1 when fetching single Apple Music song 2026-04-24 12:06:49 -03:00
Rafael Moraes aca3339b16 Remove string fallback for media_index 2026-04-24 12:04:52 -03:00
Rafael Moraes 6d6f9f4441 Provide index=0 to _get_song_media call 2026-04-24 12:01:51 -03:00
Rafael Moraes fe98bdb42c Process download items inline, remove queue 2026-04-24 11:55:35 -03:00
Rafael Moraes 7c8b20d8f3 Include track index/total in media objects 2026-04-24 11:55:11 -03:00
Rafael Moraes 6232493eed Add index and total fields to AppleMusicMedia 2026-04-24 11:54:57 -03:00
Rafael Moraes 09997bd6a1 Document --wrapper-m3u8-ip CLI option 2026-04-24 11:36:32 -03:00
Rafael Moraes 54c318908c Bump version to 3.1 2026-04-24 11:33:59 -03:00
Rafael Moraes dc6f2e8506 Use ExceptionPrettyPrinter and .exception logging 2026-04-24 11:26:21 -03:00
Rafael Moraes eff41a40f5 Await get_wrapper_m3u8 call 2026-04-24 11:22:33 -03:00
Rafael Moraes b00163a71c Add optional m3u8 wrapper support 2026-04-24 11:18:01 -03:00
Rafael Moraes 9f60043375 Add wrapper m3u8 IP and consolidate use_wrapper 2026-04-24 11:17:34 -03:00
Rafael Moraes 004ecd7c64 Guard against missing response on HTTP errors 2026-04-24 11:17:04 -03:00
Rafael Moraes 581bb7e094 Make GamdlApiResponseError.content optional 2026-04-24 11:15:57 -03:00
Rafael Moraes 5fd10d897e Extract cover URL formatting to helper 2026-04-23 11:45:57 -03:00
32 changed files with 3709 additions and 1987 deletions
+90 -102
View File
@@ -23,31 +23,40 @@ A command-line app for downloading Apple Music songs, music videos and post vide
### Required
- **Python 3.10 or higher**
- **Apple Music Cookies** - Export your browser cookies in Netscape format while logged in with an active subscription at the Apple Music website:
- **Active Apple Music subscription**
- **Apple Music Cookies** - export your browser cookies in Netscape format while logged in at [Apple Music](https://music.apple.com):
- **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)
### Dependencies
### Optional Dependencies
Add these tools to your system PATH or specify their paths via command-line arguments or the config file. The tools needed depend on which audio quality, video format, and download mode you want. Use the table below to find the required tools for your use case:
#### Wrapper
| Use Case | Configuration | Required Tools |
|---|---|---|
| **Songs in Legacy Codecs** | `song_codec_priority: aac-legacy\|aac-he-legacy` | None |
| **Songs in Non Legacy Codecs** | `song_codec_priority: aac\|aac-he\|aac-binaural\|aac-downmix\|aac-he-binaural\|aac-he-downmix\|atmos\|ac3`<br/>`use_wrapper: true` | Wrapper |
| **Music Videos** | `music_video_remux_mode: ffmpeg` | FFmpeg<br/>mp4decrypt |
| | `music_video_remux_mode: mp4box` | MP4Box<br/>mp4decrypt |
| **Faster Downloads** | `download_mode: nm3u8dlre` | N_m3u8DL-RE |
Run the [Wrapper v2](https://github.com/glomatico/wrapper-v2) server for wrapper-backed account, playback, and decryption requests. Enable it with `--use-wrapper` or `use_wrapper = true`, and configure the base URL with `--wrapper-url` or `wrapper_url`.
#### Tool Reference
The wrapper is recommended when using these non-web song codecs:
| Tool | Download | Purpose |
|---|---|---|
| **FFmpeg** | [Windows](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases) / [Linux](https://johnvansickle.com/ffmpeg/) | Required for music video remuxing with FFmpeg mode |
| **MP4Box** | [Download](https://gpac.io/downloads/gpac-nightly-builds/) | Alternative for music video remuxing |
| **mp4decrypt** | [Download](https://www.bento4.com/downloads/) | Decrypts MP4 files when used with MP4Box |
| **N_m3u8DL-RE** | [Download](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest) | Faster download alternative |
| **Wrapper** | [Download](https://github.com/WorldObservationLog/wrapper) | For downloading songs in ALAC and other experimental codecs |
- `aac`
- `aac-he`
- `aac-binaural`
- `aac-downmix`
- `aac-he-binaural`
- `aac-he-downmix`
- `atmos`
- `ac3`
- `alac`
**Note:**
- When using the Wrapper, you'll be asked to insert your credentials to login if you haven't already.
- Web song codecs such as `aac-web` and `aac-he-web` do not require the wrapper.
- Cookies can be skipped when using the wrapper.
#### N_m3u8DL-RE
Use [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest) as a faster download alternative to the default yt-dlp download mode. Enable it with `--download-mode nm3u8dlre` or `download_mode = nm3u8dlre`.
If the executable is not available in your system PATH, set its location with `--nm3u8dlre-path` or `nm3u8dlre_path`.
## 📦 Installation
@@ -61,9 +70,8 @@ Add these tools to your system PATH or specify their paths via command-line argu
- Place the cookies file in the working directory as `cookies.txt`, or
- Specify the path using `--cookies-path` or in the config file
3. **Optional: Set up tools** (only if you need the functionality)
See the [Dependencies](#dependencies) section to determine which tools you need based on your use case, then follow the [Tool Reference](#tool-reference) for download and installation instructions.
3. **Optional: Set up dependencies** (only if you need the functionality)
See the [Optional Dependencies](#optional-dependencies) section to determine which optional tools you need.
## 🚀 Usage
@@ -123,65 +131,59 @@ 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` |
| `--artist-auto-select` | Automatically select artist content to download (artist URLs) | - |
| `--database-path` | Path to the SQLite database file for registering downloaded media | - |
| `--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** | | |
| `--cover-format` | Cover format | `jpg` |
| `--cover-size` | Cover size in pixels | `1200` |
| `--wvd-path` | .wvd file path | - |
| **Song Options** | | |
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
| `--song-codec-priority` | Comma-separated codec priority | `aac-legacy` |
| `--use-album-date` | Use album release date for songs | `false` |
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
| **Music Video Options** | | |
| `--music-video-resolution` | Max music video resolution | `1080p` |
| `--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` |
| **Post Video Options** | | |
| `--uploaded-video-quality` | Post video quality | `best` |
| **Download & Path Options** | | |
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
| `--temp-path` | Temporary directory path | `.` |
| `--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 for decrypting songs | `false` |
| `--wrapper-decrypt-ip` | Wrapper decryption server IP | `127.0.0.1:10020` |
| `--download-mode` | Download mode | `ytdlp` |
| **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` |
| `--playlist-folder-template` | Playlist folder template | `Playlists/{playlist_artist}/{playlist_title}` |
| `--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 | - |
| `--truncate` | Max filename length | - |
| **File Output Options** | | |
| `--overwrite` | Overwrite existing files | `false` |
| `--save-cover`, `-s` | Save cover as separate file | `false` |
| `--save-playlist` | Save M3U8 playlist file | `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` |
| `--artist-auto-select` | Automatically select artist content to download (artist URLs) | - |
| `--database-path` | Path to the SQLite database file for registering downloaded media | - |
| `--no-config-file`, `-n` | Don't use a config file | `false` |
| **Apple Music Options** | | |
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
| `--wrapper-url` | Wrapper base URL | `http://127.0.0.1` |
| `--language`, `-l` | Metadata language | `en-US` |
| **Interface Options** | | |
| `--cover-format` | Cover format | `jpg` |
| `--cover-size` | Cover size in pixels | `1200` |
| `--wvd-path` | .wvd file path | - |
| `--use-wrapper` | Use wrapper for account, playback, and decryption requests | `false` |
| **Song Options** | | |
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
| `--song-codec-priority` | Comma-separated codec priority | `aac-web` |
| `--use-album-date` | Use album release date for songs | `false` |
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
| **Music Video Options** | | |
| `--music-video-resolution` | Max music video resolution | `1080p` |
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
| `--music-video-remux-format` | Music video remux format | `m4v` |
| **Post Video Options** | | |
| `--uploaded-video-quality` | Post video quality | `best` |
| **Download & Path Options** | | |
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
| `--temp-path` | Temporary directory path | `.` |
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
| `--download-mode` | Download mode | `ytdlp` |
| **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` |
| `--playlist-folder-template` | Playlist folder template | `Playlists/{playlist_artist}` |
| `--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 | `{playlist_title}` |
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
| `--exclude-tags` | Comma-separated tags to exclude | - |
| `--truncate` | Max filename length | - |
| **File Output Options** | | |
| `--overwrite` | Overwrite existing files | `false` |
| `--save-cover`, `-s` | Save cover as separate file | `false` |
| `--save-playlist` | Save M3U8 playlist file | `false` |
### Template Variables
@@ -212,13 +214,9 @@ The file is created automatically on first run. Command-line arguments override
- `ytdlp`, `nm3u8dlre`
> [!NOTE]
>
> - **yt-dlp is only used as a file download library**. Media is still fetched directly from Apple Music's servers, and yt-dlp is only responsible for handling the file download process.
### Remux Mode
- `ffmpeg`
- `mp4box` - Preserve the original closed caption track in music videos and some other minor metadata
### Cover Format
- `jpg`
@@ -231,12 +229,12 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
### Song Codecs
**Stable:**
**Web:**
- `aac-legacy` - AAC 256kbps 44.1kHz
- `aac-he-legacy` - AAC-HE 64kbps 44.1kHz
- `aac-web` - AAC 256kbps 44.1kHz
- `aac-he-web` - AAC-HE 64kbps 44.1kHz
**Experimental** (may not work due to API limitations):
**Non-web** (wrapper recommended; may not work without wrapper due to API limitations):
- `aac` - AAC 256kbps up to 48kHz
- `aac-he` - AAC-HE 64kbps up to 48kHz
@@ -246,8 +244,8 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
- `aac-he-downmix` - AAC-HE 64kbps downmix
- `atmos` - Dolby Atmos 768kbps
- `ac3` - AC3 640kbps
- `alac` - ALAC up to 24-bit/192kHz (unsupported)
- `ask` - Interactive experimental codec selection
- `alac` - ALAC up to 24-bit/192kHz
- `ask` - Interactive codec selection
### Synced Lyrics Format
@@ -285,16 +283,6 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
- `top-songs`
- `music-videos`
## ⚙️ Wrapper
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. **Start the wrapper server** - Run the wrapper server
2. **Enable wrapper in Gamdl** - Use `--use-wrapper` flag or set `use_wrapper = true` in config
3. **Run Gamdl** - Download as usual with the wrapper enabled
## 🐍 Embedding
Use Gamdl as a library in your Python projects:
@@ -374,7 +362,7 @@ async def main():
# Download from URL
url = "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
download_queue = []
async for media in downloader.get_download_item_from_url(url):
download_queue.append(media)
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.0"
__version__ = "3.6"
+1
View File
@@ -1,3 +1,4 @@
from .apple_music import AppleMusicApi
from .exceptions import *
from .itunes import ItunesApi
from .wrapper import WrapperApi
+28 -25
View File
@@ -24,6 +24,7 @@ from .constants import (
APPLE_MUSIC_WEBPLAYBACK_API_URL,
)
from .exceptions import GamdlApiResponseError
from .wrapper import WrapperApi
logger = structlog.get_logger(__name__)
@@ -70,6 +71,7 @@ class AppleMusicApi:
async def get_token() -> str:
log = logger.bind(action="get_token")
response = None
async with httpx.AsyncClient() as client:
try:
response = await client.get(
@@ -81,7 +83,7 @@ class AppleMusicApi:
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching Apple Music homepage",
status_code=response.status_code,
status_code=response.status_code if response is not None else None,
)
index_js_uri_match = re.search(
@@ -94,6 +96,7 @@ class AppleMusicApi:
)
index_js_uri = index_js_uri_match.group(1)
response = None
async with httpx.AsyncClient(follow_redirects=True) as client:
try:
response = await client.get(
@@ -104,7 +107,7 @@ class AppleMusicApi:
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching index.js page",
status_code=response.status_code,
status_code=response.status_code if response is not None else None,
)
token_match = re.search('(?=eyJh)(.*?)(?=")', index_js_page)
@@ -124,6 +127,7 @@ class AppleMusicApi:
) -> dict:
log = logger.bind(action="get_account_info", meta=meta)
response = None
async with httpx.AsyncClient() as client:
try:
response = await client.get(
@@ -142,7 +146,7 @@ class AppleMusicApi:
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching account info",
status_code=response.status_code,
status_code=response.status_code if response is not None else None,
)
log.debug("success", account_info=account_info)
@@ -239,24 +243,22 @@ class AppleMusicApi:
@classmethod
async def create_from_wrapper(
cls,
wrapper_account_url: str = "http://127.0.0.1:30020/",
wrapper_api: WrapperApi,
*args,
**kwargs,
) -> "AppleMusicApi":
async with httpx.AsyncClient() as client:
try:
response = await client.get(wrapper_account_url)
response.raise_for_status()
wrapper_account_info = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching wrapper account info",
status_code=response.status_code,
)
auth = wrapper_api.me.get("auth", {})
media_user_token = auth.get("music_user_token")
token = auth.get("dev_token")
if not media_user_token or not token:
raise GamdlApiResponseError(
"Wrapper account info is missing auth tokens",
status_code=None,
)
return await cls.create(
media_user_token=wrapper_account_info["music_token"],
token=wrapper_account_info["dev_token"],
media_user_token=media_user_token,
token=token,
*args,
**kwargs,
)
@@ -266,6 +268,7 @@ class AppleMusicApi:
uri: str,
params: dict | None = None,
) -> dict:
response = None
try:
response = await self.client.get(
APPLE_MUSIC_AMP_API_URL + uri,
@@ -276,8 +279,8 @@ class AppleMusicApi:
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching from AMP API",
content=response.text,
status_code=response.status_code,
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
if "errors" in response_json:
@@ -513,13 +516,11 @@ class AppleMusicApi:
else:
limit = None
offset = int(next_params["offset"][0])
extended_data = await self._amp_request(
urlparse(next_uri).path,
{
"offset": offset,
**({"limit": limit} if limit else {}),
**{k: v for k, v in next_params.items() if k not in ["limit"]},
},
)
@@ -533,6 +534,7 @@ class AppleMusicApi:
) -> dict:
log = logger.bind(action="get_webplayback", track_id=track_id)
response = None
try:
response = await self.client.post(
APPLE_MUSIC_WEBPLAYBACK_API_URL,
@@ -546,8 +548,8 @@ class AppleMusicApi:
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching webplayback data",
content=response.text,
status_code=response.status_code,
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
if "dialog" in webplayback:
@@ -570,6 +572,7 @@ class AppleMusicApi:
) -> dict:
log = logger.bind(action="get_license_exchange", track_id=track_id)
response = None
try:
response = await self.client.post(
APPLE_MUSIC_LICENSE_API_URL,
@@ -587,8 +590,8 @@ class AppleMusicApi:
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching license exchange data",
content=response.text,
status_code=response.status_code,
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
if license_exchange.get("status") != 0:
+1 -1
View File
@@ -9,7 +9,7 @@ class GamdlApiResponseError(GamdlApiError):
def __init__(
self,
message: str,
content: str,
content: str | None = None,
status_code: int | None = None,
):
self.message = message
+10 -6
View File
@@ -30,6 +30,7 @@ class ItunesApi:
async def get_storefront_id(storefront: str) -> int:
log = logger.bind(action="get_storefront_id", storefront=storefront)
response = None
async with httpx.AsyncClient() as client:
try:
response = await client.get(APPLE_MUSIC_MUSIC_KIT_URL)
@@ -38,7 +39,7 @@ class ItunesApi:
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching MusicKit content",
status_code=response.status_code,
status_code=response.status_code if response is not None else None,
)
normalized_storefront = storefront.upper()
@@ -76,6 +77,7 @@ class ItunesApi:
client = httpx.AsyncClient(
timeout=60.0,
follow_redirects=True,
)
return cls(
@@ -92,6 +94,7 @@ class ItunesApi:
) -> dict:
log = logger.bind(action="get_lookup_result", media_id=media_id, entity=entity)
response = None
try:
response = await self.client.get(
ITUNES_LOOKUP_API_URL,
@@ -107,8 +110,8 @@ class ItunesApi:
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching iTunes lookup result",
content=response.text,
status_code=response.status_code,
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
log.debug("success", lookup_result=lookup_result)
@@ -126,11 +129,12 @@ class ItunesApi:
media_id=media_id,
)
response = None
try:
response = await self.client.get(
ITUNES_PAGE_API_URL.format(media_type=media_type, media_id=media_id),
headers={
"X-Apple-Store-Front": f"{self.storefront_id}-1,32 t:music31",
"X-Apple-Store-Front": f"{self.storefront_id},32 t:music31",
},
)
response.raise_for_status()
@@ -138,8 +142,8 @@ class ItunesApi:
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching iTunes page",
content=response.text,
status_code=response.status_code,
content=response.text if response is not None else None,
status_code=response.status_code if response is not None else None,
)
log.debug("success", itunes_page=itunes_page)
+272
View File
@@ -0,0 +1,272 @@
from __future__ import annotations
import inspect
import struct
from collections.abc import Awaitable, Callable
from typing import TypeVar
import httpx
import structlog
from .exceptions import GamdlApiResponseError
logger = structlog.get_logger(__name__)
T = TypeVar("T")
CredentialsFunc = (
Callable[[], tuple[str, str]] | Callable[[], Awaitable[tuple[str, str]]]
)
TwoFactorCodeFunc = Callable[[], str] | Callable[[], Awaitable[str]]
async def _invoke(func: Callable[[], T | Awaitable[T]]) -> T:
result = func()
if inspect.isawaitable(result):
return await result
return result
class WrapperApi:
def __init__(
self,
base_url: str,
client: httpx.AsyncClient,
me: dict,
):
self.base_url = base_url
self.client = client
self.me = me
@staticmethod
def build_decrypt_sample_frame(
adam_id: str,
skd_uri: str,
ciphertexts: list[bytes],
) -> bytes:
"""Build wrapper-v2 /decrypt binary request frame."""
adam_id_bytes = adam_id.encode("utf-8")
skd_uri_bytes = skd_uri.encode("utf-8")
if not adam_id_bytes:
raise ValueError("wrapper-v2: adam_id must not be empty")
if not skd_uri_bytes:
raise ValueError("wrapper-v2: skd_uri must not be empty")
if not ciphertexts:
raise ValueError("wrapper-v2: ciphertext batch must not be empty")
frame = bytearray()
frame += struct.pack(
">III",
len(adam_id_bytes),
len(skd_uri_bytes),
len(ciphertexts),
)
for ciphertext in ciphertexts:
frame += struct.pack(">I", len(ciphertext))
frame += adam_id_bytes
frame += skd_uri_bytes
for ciphertext in ciphertexts:
frame += ciphertext
return bytes(frame)
@staticmethod
def parse_decrypt_sample_frame(data: bytes, expected_count: int) -> list[bytes]:
"""Parse wrapper-v2 /decrypt binary response frame."""
if len(data) < 4:
raise IOError("wrapper-v2: POST /decrypt returned a truncated response")
(sample_count,) = struct.unpack_from(">I", data, 0)
if sample_count != expected_count:
raise IOError(
f"wrapper-v2: expected {expected_count} samples in response, "
f"got {sample_count}"
)
table_end = 4 + sample_count * 4
if len(data) < table_end:
raise IOError("wrapper-v2: POST /decrypt returned a truncated length table")
lengths = [
struct.unpack_from(">I", data, 4 + i * 4)[0] for i in range(sample_count)
]
offset = table_end
out: list[bytes] = []
for i, length in enumerate(lengths):
end = offset + length
if end > len(data):
raise IOError(
f"wrapper-v2: POST /decrypt returned truncated sample {i}"
)
out.append(data[offset:end])
offset = end
if offset != len(data):
raise IOError("wrapper-v2: POST /decrypt returned trailing bytes")
return out
@classmethod
async def create(
cls,
base_url: str = "http://127.0.0.1",
get_credentials_func: CredentialsFunc | None = None,
get_2fa_code: TwoFactorCodeFunc | None = None,
) -> WrapperApi:
client = httpx.AsyncClient(
timeout=httpx.Timeout(600.0, connect=30.0),
)
base_url = base_url.rstrip("/")
me = await cls.get_me(client, base_url)
if get_credentials_func is not None and me["auth"]["state"] == "logged_out":
username, password = await _invoke(get_credentials_func)
await cls.login(
client,
base_url,
username,
password,
get_2fa_code,
)
me = await cls.get_me(client, base_url)
if me.get("auth", {}).get("state") == "logged_out":
raise GamdlApiResponseError(
"Wrapper is not authenticated. "
"Provide get_credentials_func or log in via the wrapper.",
)
return cls(base_url, client, me)
@staticmethod
async def login(
client: httpx.AsyncClient,
base_url: str,
username: str,
password: str,
get_2fa_code: TwoFactorCodeFunc | None = None,
) -> None:
base_url = base_url.rstrip("/")
response = await client.post(
f"{base_url}/login",
json={"username": username, "password": password},
)
if response.status_code == 200:
return
if response.status_code == 202:
if get_2fa_code is None:
raise GamdlApiResponseError(
"Wrapper login requires 2FA; provide get_2fa_code",
status_code=202,
)
code = await _invoke(get_2fa_code)
tfa_response = await client.post(
f"{base_url}/login/2fa",
json={"code": code},
)
if tfa_response.is_error:
raise GamdlApiResponseError(
"Wrapper 2FA login failed",
content=tfa_response.text,
status_code=tfa_response.status_code,
)
return
raise GamdlApiResponseError(
"Wrapper login failed",
content=response.text,
status_code=response.status_code,
)
@staticmethod
async def get_me(client: httpx.AsyncClient, base_url: str) -> dict:
log = logger.bind(action="wrapper_get_me")
response = None
try:
response = await client.get(f"{base_url}/me")
response.raise_for_status()
account_info = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching wrapper account info",
content=getattr(response, "text", None),
status_code=getattr(response, "status_code", None),
)
log.debug("success", account_info=account_info)
return account_info
async def get_playback(self, media_id: str) -> dict:
log = logger.bind(action="wrapper_get_playback", media_id=media_id)
response = None
try:
response = await self.client.get(
f"{self.base_url}/playback",
params={"adam_id": media_id},
)
response.raise_for_status()
playback = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching wrapper playback",
content=getattr(response, "text", None),
status_code=getattr(response, "status_code", None),
)
log.debug("success", playback=playback)
return playback
async def decrypt(
self,
adam_id: str,
skd_uri: str,
ciphertexts: list[bytes],
) -> list[bytes]:
"""Decrypt one POST /decrypt batch; plaintexts match ciphertext order."""
log = logger.bind(
action="wrapper_decrypt",
adam_id=adam_id,
sample_count=len(ciphertexts),
)
frame = self.build_decrypt_sample_frame(adam_id, skd_uri, ciphertexts)
response = await self.client.post(
f"{self.base_url}/decrypt",
content=frame,
headers={
"content-type": "application/octet-stream",
"accept": "application/octet-stream",
},
)
if response.status_code == 401:
raise IOError(
"wrapper-v2: POST /decrypt returned 401 — log in with POST /login "
"or restore a session on the daemon first"
)
if response.status_code == 503:
raise IOError(
"wrapper-v2: decrypt unavailable (503) — check daemon logs /health "
"for playback_ready and Apple lib init"
)
if response.status_code != 200:
detail = ""
try:
j = response.json()
detail = (j.get("detail") or j.get("error") or str(j)) or ""
except Exception:
detail = (response.text or "")[:500]
raise IOError(
f"wrapper-v2: POST /decrypt failed HTTP {response.status_code}: {detail}"
)
plaintexts = self.parse_decrypt_sample_frame(
response.content,
len(ciphertexts),
)
log.debug("success")
return plaintexts
+88 -92
View File
@@ -1,6 +1,4 @@
import asyncio
import logging
import traceback
from functools import wraps
from pathlib import Path
@@ -12,15 +10,14 @@ from httpx import ConnectError
from .. import __version__
from ..api import AppleMusicApi
from ..api.wrapper import WrapperApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
AppleMusicMusicVideoDownloader,
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
DownloadItem,
GamdlDownloaderDependencyNotFoundError,
GamdlDownloaderFlatFilterExcludedError,
GamdlDownloaderMediaFileExistsError,
GamdlDownloaderSyncedLyricsOnlyError,
)
@@ -32,6 +29,7 @@ from ..interface import (
AppleMusicUploadedVideoInterface,
GamdlInterfaceArtistMediaTypeError,
GamdlInterfaceDecryptionNotAvailableError,
GamdlInterfaceFlatFilterExcludedError,
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceMediaNotStreamableError,
GamdlInterfaceUrlParseError,
@@ -40,7 +38,7 @@ from .cli_config import CliConfig
from .config_file import ConfigFile
from .database import Database
from .interactive_prompts import InteractivePrompts
from .utils import custom_structlog_formatter, prompt_path
from .utils import CustomOutputWriter, custom_structlog_formatter, prompt_path
logger = structlog.get_logger(__name__)
@@ -62,40 +60,40 @@ def make_sync(func):
async def main(config: CliConfig):
colorama.just_fix_windows_console()
root_logger = logging.getLogger(__name__.split(".")[0])
root_logger.setLevel(config.log_level)
root_logger.propagate = False
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(logging.Formatter("%(message)s"))
root_logger.addHandler(stream_handler)
log_output = CustomOutputWriter()
if config.log_file:
file_handler = logging.FileHandler(config.log_file, encoding="utf-8")
file_handler.setFormatter(logging.Formatter("%(message)s"))
root_logger.addHandler(file_handler)
log_output.add_file(config.log_file)
structlog.configure(
processors=[
structlog.processors.add_log_level,
structlog.processors.ExceptionPrettyPrinter(),
custom_structlog_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
logger_factory=structlog.PrintLoggerFactory(file=log_output),
wrapper_class=structlog.make_filtering_bound_logger(config.log_level),
)
logger.info(f"Starting Gamdl {__version__}")
interactive_prompts = InteractivePrompts(
artist_auto_select=config.artist_auto_select,
)
if config.use_wrapper:
try:
wrapper_api = await WrapperApi.create(
base_url=config.wrapper_url,
get_credentials_func=InteractivePrompts.get_wrapper_credentials,
get_2fa_code=InteractivePrompts.get_wrapper_2fa_code,
)
apple_music_api = await AppleMusicApi.create_from_wrapper(
wrapper_account_url=config.wrapper_account_url,
wrapper_api=wrapper_api,
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."
)
except Exception as e:
logger.exception(f"Error: {e}")
return
else:
cookies_path = prompt_path(config.cookies_path)
@@ -103,6 +101,7 @@ async def main(config: CliConfig):
cookies_path=cookies_path,
language=config.language,
)
wrapper_api = None
if not apple_music_api.active_subscription:
logger.critical(
@@ -118,7 +117,7 @@ async def main(config: CliConfig):
)
if (
any(not codec.is_legacy() for codec in config.song_codec_piority)
any(not codec.is_web for codec in config.song_codec_piority)
and not config.use_wrapper
):
logger.warning(
@@ -128,21 +127,18 @@ async def main(config: CliConfig):
)
if config.database_path:
database = Database(config.database_path)
database = Database(config.database_path, config.overwrite)
flat_filter = database.flat_filter
else:
database = None
flat_filter = None
interactive_prompts = InteractivePrompts(
artist_auto_select=config.artist_auto_select,
)
base_interface = await AppleMusicBaseInterface.create(
apple_music_api=apple_music_api,
cover_format=config.cover_format,
cover_size=config.cover_size,
wvd_path=config.wvd_path,
wrapper_api=wrapper_api,
)
song_interface = AppleMusicSongInterface(
@@ -150,7 +146,6 @@ async def main(config: CliConfig):
synced_lyrics_format=config.synced_lyrics_format,
codec_priority=config.song_codec_piority,
use_album_date=config.use_album_date,
skip_decryption_key_non_legacy=config.use_wrapper,
skip_stream_info=config.synced_lyrics_only,
ask_codec_function=interactive_prompts.ask_song_codec,
)
@@ -181,11 +176,6 @@ async def main(config: CliConfig):
output_path=config.output_path,
temp_path=config.temp_path,
nm3u8dlre_path=config.nm3u8dlre_path,
mp4decrypt_path=config.mp4decrypt_path,
ffmpeg_path=config.ffmpeg_path,
mp4box_path=config.mp4box_path,
use_wrapper=config.use_wrapper,
wrapper_decrypt_ip=config.wrapper_decrypt_ip,
download_mode=config.download_mode,
album_folder_template=config.album_folder_template,
compilation_folder_template=config.compilation_folder_template,
@@ -205,7 +195,6 @@ async def main(config: CliConfig):
)
music_video_downloader = AppleMusicMusicVideoDownloader(
base=base_downloader,
remux_mode=config.music_video_remux_mode,
remux_format=config.music_video_remux_format,
)
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
@@ -245,64 +234,71 @@ async def main(config: CliConfig):
url_log.info(f'Processing "{url}"')
try:
download_queue: list[DownloadItem] = []
async for media in downloader.get_download_item_from_url(url):
download_queue.append(media)
except GamdlInterfaceUrlParseError as e:
url_log.warning(f"{e}")
continue
except Exception as e:
url_log.error(f'Error processing "{url}": {e}')
error_count += 1
if not config.no_exceptions:
traceback.print_exc()
continue
async for download_item in downloader.get_download_item_from_url(url):
media_index = download_item.media.index + 1
media_total = download_item.media.total or "-"
for download_index, download_item in enumerate(
download_queue,
1,
):
track_log = logger.bind(
action=f"Track {download_index:>3}/{len(download_queue):<3}"
)
media_title = (
download_item.media.media_metadata["attributes"]["name"]
if download_item.media.media_metadata
and download_item.media.media_metadata.get("attributes", {}).get("name")
else "Unknown Title"
)
track_log.info(f'Downloading "{media_title}"')
try:
await downloader.download(download_item)
except (
GamdlInterfaceMediaNotStreamableError,
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceDecryptionNotAvailableError,
GamdlInterfaceArtistMediaTypeError,
GamdlDownloaderSyncedLyricsOnlyError,
GamdlDownloaderMediaFileExistsError,
GamdlDownloaderDependencyNotFoundError,
GamdlDownloaderFlatFilterExcludedError,
) as e:
track_log.warning(f'Skipping "{media_title}": {e}')
continue
except Exception as e:
error_count += 1
track_log.error(f'Error downloading "{media_title}"')
if not config.no_exceptions:
traceback.print_exc()
if (
database
and download_item.media.media_metadata
and download_item.final_path
):
database.add(
download_item.media.media_metadata["id"],
download_item.final_path,
track_log = logger.bind(
action=f"Track {media_index:>3}/{media_total:<3}"
)
media_title = (
download_item.media.media_metadata["attributes"]["name"]
if download_item.media.media_metadata
and download_item.media.media_metadata.get("attributes", {}).get(
"name"
)
else "Unknown Title"
)
media_type = (
download_item.media.media_metadata["type"]
if download_item.media.media_metadata
else None
)
if download_item.media.partial and media_type in {
None,
"songs",
"library-songs",
"music-videos",
"library-music-videos",
"uploaded-videos",
}:
track_log.info(f'Downloading "{media_title}"')
try:
await downloader.download(download_item)
except (
GamdlInterfaceMediaNotStreamableError,
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceDecryptionNotAvailableError,
GamdlInterfaceArtistMediaTypeError,
GamdlDownloaderSyncedLyricsOnlyError,
GamdlDownloaderMediaFileExistsError,
GamdlDownloaderDependencyNotFoundError,
GamdlInterfaceFlatFilterExcludedError,
) as e:
track_log.warning(f'Skipping "{media_title}": {e}')
continue
except Exception as e:
error_count += 1
track_log.exception(f'Error downloading "{media_title}"')
if (
database
and download_item.media.media_metadata
and download_item.final_path
):
database.add(
download_item.media.media_metadata["id"],
download_item.final_path,
)
except GamdlInterfaceUrlParseError as e:
url_log.error(f"{e}")
continue
except Exception as e:
url_log.exception(f'Error processing "{url}": {e}')
error_count += 1
continue
logger.info(f"Finished with {error_count} error(s)")
+22 -62
View File
@@ -6,7 +6,7 @@ from typing import Annotated
import click
from dataclass_click import argument, option
from ..api import AppleMusicApi
from ..api import AppleMusicApi, WrapperApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
@@ -32,7 +32,7 @@ from ..interface import (
from .utils import Csv
api_from_cookies_sig = inspect.signature(AppleMusicApi.create_from_netscape_cookies)
api_from_wrapper_sig = inspect.signature(AppleMusicApi.create_from_wrapper)
wrapper_api_create_sig = inspect.signature(WrapperApi.create)
api_create_sig = inspect.signature(AppleMusicApi.create)
base_interface_create_sig = inspect.signature(AppleMusicBaseInterface.create)
@@ -145,6 +145,15 @@ class CliConfig:
is_flag=True,
),
]
# Wrapper specific options
wrapper_url: Annotated[
str,
option(
"--wrapper-url",
help="Wrapper base URL",
default=wrapper_api_create_sig.parameters["base_url"].default,
),
]
# API specific options
cookies_path: Annotated[
str,
@@ -161,14 +170,6 @@ class CliConfig:
),
),
]
wrapper_account_url: Annotated[
str,
option(
"--wrapper-account-url",
help="Wrapper account URL",
default=api_from_wrapper_sig.parameters["wrapper_account_url"].default,
),
]
language: Annotated[
str,
option(
@@ -203,13 +204,21 @@ class CliConfig:
help=".wvd file path",
default=base_interface_create_sig.parameters["wvd_path"].default,
type=click.Path(
file_okay=False,
dir_okay=True,
writable=True,
file_okay=True,
dir_okay=False,
writable=False,
resolve_path=True,
),
),
]
use_wrapper: Annotated[
bool,
option(
"--use-wrapper",
help="Use wrapper for account, playback, and decryption requests",
is_flag=True,
),
]
# Song Interface Options
synced_lyrics_format: Annotated[
SyncedLyricsFormat,
@@ -304,46 +313,6 @@ class CliConfig:
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
),
]
mp4decrypt_path: Annotated[
str,
option(
"--mp4decrypt-path",
help="mp4decrypt executable path",
default=base_downloader_sig.parameters["mp4decrypt_path"].default,
),
]
ffmpeg_path: Annotated[
str,
option(
"--ffmpeg-path",
help="FFmpeg executable path",
default=base_downloader_sig.parameters["ffmpeg_path"].default,
),
]
mp4box_path: Annotated[
str,
option(
"--mp4box-path",
help="MP4Box executable path",
default=base_downloader_sig.parameters["mp4box_path"].default,
),
]
use_wrapper: Annotated[
bool,
option(
"--use-wrapper",
help="Use wrapper for decrypting songs",
is_flag=True,
),
]
wrapper_decrypt_ip: Annotated[
str,
option(
"--wrapper-decrypt-ip",
help="IP address and port for wrapper decryption",
default=base_downloader_sig.parameters["wrapper_decrypt_ip"].default,
),
]
download_mode: Annotated[
DownloadMode,
option(
@@ -445,15 +414,6 @@ class CliConfig:
),
]
# DownloaderMusicVideo specific options
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(
+12 -2
View File
@@ -3,7 +3,13 @@ from pathlib import Path
class Database:
def __init__(self, path: Path):
def __init__(
self,
path: Path,
overwrite: bool,
):
self.overwrite = overwrite
self.connection = sqlite3.connect(path)
self.cursor = self.connection.cursor()
self._create_tables()
@@ -45,4 +51,8 @@ class Database:
if not result:
return None
return result if Path(result).exists() else None
return (
"Registered in database"
if Path(result).exists() and not self.overwrite
else None
)
+16
View File
@@ -16,6 +16,22 @@ class InteractivePrompts:
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02}:{seconds:02}"
@staticmethod
async def get_wrapper_credentials() -> tuple[str, str]:
username = await inquirer.text(
message="Apple ID:",
).execute_async()
password = await inquirer.secret(
message="Password:",
).execute_async()
return username, password
@staticmethod
async def get_wrapper_2fa_code() -> str:
return await inquirer.text(
message="Two-factor authentication code:",
).execute_async()
@staticmethod
async def ask_song_codec(
playlists: list[dict],
+25 -2
View File
@@ -1,3 +1,5 @@
import atexit
import sys
from datetime import datetime
from enum import Enum
from pathlib import Path
@@ -39,12 +41,33 @@ class Csv(click.ParamType):
return result
class CustomOutputWriter:
def __init__(
self,
streams: list[Any] = [sys.stdout],
):
self.streams = streams
def add_file(self, path: str):
file_stream = open(path, "a", encoding="utf-8")
atexit.register(file_stream.close)
self.streams.append(file_stream)
def write(self, message: str):
for stream in self.streams:
stream.write(message)
def flush(self):
for stream in self.streams:
stream.flush()
def custom_structlog_formatter(
logger: Any,
name: str,
event_dict: dict[str, Any],
) -> str:
level = event_dict.get("level", "INFO").upper()
level = event_dict.pop("level", "INFO").upper()
timestamp = datetime.now().strftime("%H:%M:%S")
level_colors = {
@@ -63,7 +86,7 @@ def custom_structlog_formatter(
prefix += click.style(f" [{action}]", dim=True)
if level in {"INFO", "WARNING", "ERROR", "CRITICAL"}:
message = event_dict.get("event", "")
message = event_dict.pop("event", "")
return f"{prefix} {message}"
else:
return f"{prefix} {event_dict}"
+1 -1
View File
@@ -1,4 +1,4 @@
from .amdecrypt import decrypt_file, decrypt_file_hex
from .amdecrypt import decrypt_file_hex, decrypt_wrapper, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .downloader import AppleMusicDownloader
from .enums import *
File diff suppressed because it is too large Load Diff
+59 -36
View File
@@ -1,6 +1,9 @@
import asyncio
import multiprocessing
import queue
import re
import shutil
import traceback
from pathlib import Path
import structlog
@@ -17,6 +20,30 @@ from .enums import DownloadMode
logger = structlog.get_logger(__name__)
def _download_ytdlp_process(
stream_url: str,
download_path: str,
silent: bool,
result_queue,
) -> None:
try:
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"outtmpl": download_path,
"allow_unplayable_formats": True,
"overwrites": True,
"fixup": "never",
"noprogress": silent,
"allowed_extractors": ["generic"],
}
) as ydl:
ydl.download(stream_url)
except Exception as e:
result_queue.put(("error", repr(e), traceback.format_exc()))
class AppleMusicBaseDownloader:
def __init__(
self,
@@ -24,11 +51,6 @@ class AppleMusicBaseDownloader:
output_path: str = "./Apple Music",
temp_path: str = ".",
nm3u8dlre_path: str = "N_m3u8DL-RE",
mp4decrypt_path: str = "mp4decrypt",
ffmpeg_path: str = "ffmpeg",
mp4box_path: str = "MP4Box",
use_wrapper: bool = False,
wrapper_decrypt_ip: str = "127.0.0.1:10020",
download_mode: DownloadMode = DownloadMode.YTDLP,
album_folder_template: str = "{album_artist}/{album}",
compilation_folder_template: str = "Compilations/{album}",
@@ -47,11 +69,6 @@ class AppleMusicBaseDownloader:
self.output_path = output_path
self.temp_path = temp_path
self.nm3u8dlre_path = nm3u8dlre_path
self.mp4decrypt_path = mp4decrypt_path
self.ffmpeg_path = ffmpeg_path
self.mp4box_path = mp4box_path
self.use_wrapper = use_wrapper
self.wrapper_decrypt_ip = wrapper_decrypt_ip
self.download_mode = download_mode
self.album_folder_template = album_folder_template
self.compilation_folder_template = compilation_folder_template
@@ -72,16 +89,10 @@ class AppleMusicBaseDownloader:
log = logger.bind(action="initialize_binary_paths")
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
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)
log = log.debug(
"success",
full_nm3u8dlre_path=self.full_nm3u8dlre_path,
full_mp4decrypt_path=self.full_mp4decrypt_path,
full_ffmpeg_path=self.full_ffmpeg_path,
full_mp4box_path=self.full_mp4box_path,
)
def get_temp_path(
@@ -216,26 +227,40 @@ class AppleMusicBaseDownloader:
log.debug("success")
async def _download_ytdlp_async(self, stream_url: str, download_path: str) -> None:
await asyncio.to_thread(
self._download_ytdlp_sync,
stream_url,
download_path,
ctx = multiprocessing.get_context()
result_queue = ctx.Queue()
process = ctx.Process(
target=_download_ytdlp_process,
args=(stream_url, download_path, self.silent, result_queue),
)
process.start()
def _download_ytdlp_sync(self, stream_url: str, download_path: str) -> None:
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"outtmpl": download_path,
"allow_unplayable_formats": True,
"overwrites": True,
"fixup": "never",
"noprogress": self.silent,
"allowed_extractors": ["generic"],
}
) as ydl:
ydl.download(stream_url)
try:
while process.is_alive():
await asyncio.sleep(0.1)
process.join()
try:
status, error_repr, error_traceback = result_queue.get_nowait()
except queue.Empty:
status = None
if status == "error":
raise RuntimeError(
f"yt-dlp failed: {error_repr}\n{error_traceback}"
) from None
if process.exitcode != 0:
raise RuntimeError(f"yt-dlp exited with code {process.exitcode}")
finally:
if process.is_alive():
process.terminate()
await asyncio.to_thread(process.join, 5)
if process.is_alive():
process.kill()
await asyncio.to_thread(process.join)
process.close()
async def _download_nm3u8dlre(self, stream_url: str, download_path: str):
download_path_obj = Path(download_path)
@@ -248,8 +273,6 @@ class AppleMusicBaseDownloader:
"--no-log",
"--log-level",
"off",
"--ffmpeg-binary-path",
self.full_ffmpeg_path,
"--save-name",
download_path_obj.stem,
"--save-dir",
+10 -25
View File
@@ -5,12 +5,10 @@ from typing import AsyncGenerator
import structlog
from ..interface.types import AppleMusicMedia
from .constants import TEMP_PATH_TEMPLATE
from .enums import DownloadMode, RemuxMode
from .enums import DownloadMode
from .exceptions import (
GamdlDownloaderDependencyNotFoundError,
GamdlDownloaderFlatFilterExcludedError,
GamdlDownloaderMediaFileExistsError,
GamdlDownloaderSyncedLyricsOnlyError,
)
@@ -60,7 +58,10 @@ class AppleMusicDownloader:
self,
media: AppleMusicMedia,
) -> DownloadItem:
if media.error or media.flat_filter_result:
if media.error:
return DownloadItem(media)
if media.partial:
return DownloadItem(media)
elif media.media_metadata["type"] in {"songs", "library-songs"}:
@@ -80,16 +81,15 @@ class AppleMusicDownloader:
if item.media.error:
raise item.media.error
if item.media.flat_filter_result:
raise GamdlDownloaderFlatFilterExcludedError(
item.media.media_metadata["id"]
)
if item.media.partial:
return
await self._initial_processing(item)
await self._download(item)
await self._final_processing(item)
finally:
self._cleanup_temp(item.uuid_)
if not self.skip_cleanup:
self._cleanup_temp(item.uuid_)
def _update_playlist_file(
self,
@@ -215,21 +215,6 @@ class AppleMusicDownloader:
"music-videos",
"library-music-videos",
}:
if not self.base.full_mp4decrypt_path:
raise GamdlDownloaderDependencyNotFoundError("mp4decrypt")
if (
self.music_video.remux_mode == RemuxMode.FFMPEG
and not self.base.full_ffmpeg_path
):
raise GamdlDownloaderDependencyNotFoundError("FFmpeg")
if (
self.music_video.remux_mode == RemuxMode.MP4BOX
and not self.base.full_mp4box_path
):
raise GamdlDownloaderDependencyNotFoundError("MP4Box")
await self.music_video.download(item)
elif item.media.media_metadata["type"] in {"uploaded-videos"}:
@@ -264,6 +249,6 @@ class AppleMusicDownloader:
log = logger.bind(action="cleanup_temp", folder_tag=folder_tag)
temp_path = Path(self.base.temp_path) / TEMP_PATH_TEMPLATE.format(folder_tag)
if temp_path.exists() and temp_path.is_dir() and not self.skip_cleanup:
if temp_path.exists() and temp_path.is_dir():
shutil.rmtree(temp_path, ignore_errors=True)
log.debug("success")
-5
View File
@@ -18,8 +18,3 @@ class GamdlDownloaderMediaFileExistsError(GamdlDownloaderError):
class GamdlDownloaderDependencyNotFoundError(GamdlDownloaderError):
def __init__(self, dependency_name: str) -> None:
super().__init__(f"Required dependency not found: {dependency_name}")
class GamdlDownloaderFlatFilterExcludedError(GamdlDownloaderError):
def __init__(self, media_id: str) -> None:
super().__init__(f"Media is excluded by flat filter: {media_id}")
+12 -102
View File
@@ -2,7 +2,7 @@ from pathlib import Path
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from ..utils import async_subprocess
from .amdecrypt import decrypt_file_hex, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .enums import RemuxFormatMusicVideo, RemuxMode
from .types import DownloadItem
@@ -12,106 +12,30 @@ class AppleMusicMusicVideoDownloader:
def __init__(
self,
base: AppleMusicBaseDownloader,
remux_mode: RemuxMode = RemuxMode.FFMPEG,
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
):
self.base = base
self.remux_mode = remux_mode
self.remux_format = remux_format
async def _remux_mp4box(
self,
input_path_video: str,
input_path_audio: str,
output_path: str,
):
await async_subprocess(
self.base.full_mp4box_path,
"-quiet",
"-add",
input_path_audio,
"-add",
input_path_video,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
output_path,
silent=self.base.silent,
)
async def _remux_ffmpeg(
self,
input_path_video: str,
input_path_audio: str,
output_path: str,
):
await async_subprocess(
self.base.full_ffmpeg_path,
"-loglevel",
"error",
"-y",
"-i",
input_path_video,
"-i",
input_path_audio,
"-c",
"copy",
"-c:s",
"mov_text",
"-movflags",
"+faststart",
output_path,
silent=self.base.silent,
)
async def _decrypt_mp4decrypt(
self,
input_path: str,
output_path: str,
decryption_key: str,
):
await async_subprocess(
self.base.full_mp4decrypt_path,
"--key",
f"1:{decryption_key}",
input_path,
output_path,
silent=self.base.silent,
)
async def stage(
self,
encrypted_path_video: str,
encrypted_path_audio: str,
decrypted_path_video: str,
decrypted_path_audio: str,
staged_path: str,
decryption_key: DecryptionKeyAv,
is_m4v: bool = False,
):
await self._decrypt_mp4decrypt(
encrypted_path_video,
decrypted_path_video,
decryption_key.video_track.key,
)
await self._decrypt_mp4decrypt(
encrypted_path_audio,
decrypted_path_audio,
decrypted_media = await decrypt_file_hex(
decryption_key.audio_track.key,
encrypted_path_audio,
decryption_key.video_track.key,
encrypted_path_video,
)
await write_decrypted_media(
decrypted_media,
staged_path,
m4v_brand=is_m4v,
)
if self.remux_mode == RemuxMode.MP4BOX:
await self._remux_mp4box(
decrypted_path_video,
decrypted_path_audio,
staged_path,
)
else:
await self._remux_ffmpeg(
decrypted_path_video,
decrypted_path_audio,
staged_path,
)
def get_cover_path(
self,
@@ -177,26 +101,12 @@ class AppleMusicMusicVideoDownloader:
encrypted_path_audio,
)
decrypted_path_video = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"decrypted_video",
".mp4",
)
decrypted_path_audio = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"decrypted_audio",
".m4a",
)
await self.stage(
encrypted_path_video,
encrypted_path_audio,
decrypted_path_video,
decrypted_path_audio,
download_item.staged_path,
download_item.media.decryption_key,
download_item.staged_path.endswith(".m4v"),
)
cover_bytes = (
+194 -181
View File
@@ -1,181 +1,194 @@
from pathlib import Path
import structlog
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from .amdecrypt import decrypt_file, decrypt_file_hex
from .base import AppleMusicBaseDownloader
from .types import DownloadItem
logger = structlog.get_logger(__name__)
class AppleMusicSongDownloader:
def __init__(
self,
base: AppleMusicBaseDownloader,
):
self.base = base
async def get_download_item(self, media: AppleMusicMedia) -> DownloadItem:
download_item = DownloadItem(media)
if media.stream_info:
download_item.staged_path = self.base.get_temp_path(
media.media_metadata["id"],
download_item.uuid_,
"staged",
"." + media.stream_info.file_format.value,
)
download_item.final_path = self.base.get_final_path(
media.tags,
".m4a",
media.playlist_tags,
)
if media.playlist_tags:
download_item.playlist_file_path = self.base.get_playlist_file_path(
media.playlist_tags,
)
download_item.synced_lyrics_path = self.get_synced_lyrics_path(
download_item.final_path
)
download_item.cover_path = self.get_cover_path(
download_item.final_path,
media.cover.file_extension,
)
return download_item
async def _decrypt_amdecrypt(
self,
input_path: str,
output_path: str,
media_id: str,
fairplay_key: str,
) -> None:
await decrypt_file(
self.base.wrapper_decrypt_ip,
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,
staged_path: str,
decryption_key: DecryptionKeyAv,
legacy: bool,
media_id: str,
fairplay_key: str,
):
log = logger.bind(
action="stage_song",
media_id=media_id,
encrypted_path=encrypted_path,
staged_path=staged_path,
)
if self.base.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,
)
log.debug("success")
def get_synced_lyrics_path(self, final_path: str) -> str:
log = logger.bind(action="get_synced_lyrics_path", final_path=final_path)
synced_lyrics_path = str(
Path(final_path).with_suffix(
"." + self.base.interface.song.synced_lyrics_format.value
)
)
log.debug("success", synced_lyrics_path=synced_lyrics_path)
return synced_lyrics_path
def get_cover_path(
self,
final_path: str,
file_extension: str,
) -> str:
log = logger.bind(
action="get_song_cover_path",
final_path=final_path,
file_extension=file_extension,
)
cover_path = str(Path(final_path).parent / ("Cover" + file_extension))
log.debug("success", cover_path=cover_path)
return cover_path
async def download(
self,
download_item: DownloadItem,
) -> None:
encrypted_path = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"encrypted",
".m4a",
)
await self.base.download_stream(
download_item.media.stream_info.audio_track.stream_url,
encrypted_path,
)
await self.stage(
encrypted_path,
download_item.staged_path,
download_item.media.decryption_key,
download_item.media.stream_info.audio_track.legacy,
download_item.media.media_metadata["id"],
download_item.media.stream_info.audio_track.fairplay_key,
)
cover_bytes = (
await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
if self.base.interface.base.cover_format != CoverFormat.RAW
else None
)
await self.base.apply_tags(
download_item.staged_path,
download_item.media.tags,
cover_bytes,
)
from pathlib import Path
import structlog
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from .amdecrypt import decrypt_file_hex, decrypt_wrapper, write_decrypted_media
from .base import AppleMusicBaseDownloader
from .types import DownloadItem
logger = structlog.get_logger(__name__)
class AppleMusicSongDownloader:
def __init__(
self,
base: AppleMusicBaseDownloader,
):
self.base = base
async def get_download_item(self, media: AppleMusicMedia) -> DownloadItem:
download_item = DownloadItem(media)
if media.stream_info:
download_item.staged_path = self.base.get_temp_path(
media.media_metadata["id"],
download_item.uuid_,
"staged",
"." + media.stream_info.file_format.value,
)
download_item.final_path = self.base.get_final_path(
media.tags,
".m4a",
media.playlist_tags,
)
if media.playlist_tags:
download_item.playlist_file_path = self.base.get_playlist_file_path(
media.playlist_tags,
)
download_item.synced_lyrics_path = self.get_synced_lyrics_path(
download_item.final_path
)
download_item.cover_path = self.get_cover_path(
download_item.final_path,
media.cover.file_extension,
)
return download_item
async def _decrypt_amdecrypt(
self,
input_path: str,
output_path: str,
media_id: str,
fairplay_key: str,
use_single_content_key: bool = False,
) -> None:
wrapper_api = self.base.interface.base.wrapper_api
if wrapper_api is None:
raise ValueError("wrapper_api is required for FairPlay decrypt")
decrypted_media = await decrypt_wrapper(
wrapper_api,
media_id,
input_path,
fairplay_key_audio=fairplay_key,
use_single_content_key=use_single_content_key,
)
await write_decrypted_media(decrypted_media, output_path)
async def _decrypt_amdecrypt_hex(
self,
input_path: str,
output_path: str,
decryption_key: str,
*,
use_cenc: bool = False,
use_single_content_key: bool = False,
) -> None:
decrypted_media = await decrypt_file_hex(
decryption_key,
input_path,
use_cenc=use_cenc,
use_single_content_key=use_single_content_key,
)
await write_decrypted_media(decrypted_media, output_path)
async def stage(
self,
encrypted_path: str,
staged_path: str,
media_id: str,
decryption_key: DecryptionKeyAv | None = None,
fairplay_key: str = None,
use_cenc: bool = False,
use_single_content_key: bool = False,
):
log = logger.bind(
action="stage_song",
media_id=media_id,
encrypted_path=encrypted_path,
staged_path=staged_path,
)
if decryption_key:
await self._decrypt_amdecrypt_hex(
encrypted_path,
staged_path,
decryption_key.audio_track.key,
use_cenc=use_cenc,
use_single_content_key=use_single_content_key,
)
else:
await self._decrypt_amdecrypt(
encrypted_path,
staged_path,
media_id,
fairplay_key,
use_single_content_key=use_single_content_key,
)
log.debug("success")
def get_synced_lyrics_path(self, final_path: str) -> str:
log = logger.bind(action="get_synced_lyrics_path", final_path=final_path)
synced_lyrics_path = str(
Path(final_path).with_suffix(
"." + self.base.interface.song.synced_lyrics_format.value
)
)
log.debug("success", synced_lyrics_path=synced_lyrics_path)
return synced_lyrics_path
def get_cover_path(
self,
final_path: str,
file_extension: str,
) -> str:
log = logger.bind(
action="get_song_cover_path",
final_path=final_path,
file_extension=file_extension,
)
cover_path = str(Path(final_path).parent / ("Cover" + file_extension))
log.debug("success", cover_path=cover_path)
return cover_path
async def download(
self,
download_item: DownloadItem,
) -> None:
encrypted_path = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"encrypted",
".m4a",
)
await self.base.download_stream(
download_item.media.stream_info.audio_track.stream_url,
encrypted_path,
)
await self.stage(
encrypted_path,
download_item.staged_path,
download_item.media.media_id,
download_item.media.decryption_key,
download_item.media.stream_info.audio_track.fairplay_key,
download_item.media.stream_info.audio_track.use_cenc,
download_item.media.stream_info.audio_track.use_single_content_key,
)
cover_bytes = (
await self.base.interface.base.get_cover_bytes(
download_item.media.cover.url
)
if self.base.interface.base.cover_format != CoverFormat.RAW
else None
)
await self.base.apply_tags(
download_item.staged_path,
download_item.media.tags,
cover_bytes,
)
+2 -2
View File
@@ -1,5 +1,5 @@
import uuid
from dataclasses import dataclass
from dataclasses import dataclass, field
from ..interface.types import AppleMusicMedia
@@ -7,7 +7,7 @@ from ..interface.types import AppleMusicMedia
@dataclass
class DownloadItem:
media: AppleMusicMedia
uuid_: str = uuid.uuid4().hex[:8]
uuid_: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
staged_path: str = None
final_path: str = None
playlist_file_path: str = None
+99 -9
View File
@@ -15,9 +15,10 @@ from gamdl.interface.wvd import WVD
from ..api.apple_music import AppleMusicApi
from ..api.itunes import ItunesApi
from ..api.wrapper import WrapperApi
from .constants import IMAGE_FILE_EXTENSION_MAP
from .enums import CoverFormat
from .types import Cover, DecryptionKey, PlaylistTags
from .types import Cover, DecryptionKey, MediaRating, MediaTags, MediaType, PlaylistTags
logger = structlog.get_logger(__name__)
@@ -27,6 +28,7 @@ class AppleMusicBaseInterface:
self,
apple_music_api: AppleMusicApi,
itunes_api: ItunesApi,
wrapper_api: WrapperApi | None,
cover_format: CoverFormat,
cover_size: int,
cdm: Cdm,
@@ -36,6 +38,7 @@ class AppleMusicBaseInterface:
self.cover_format = cover_format
self.cover_size = cover_size
self.cdm = cdm
self.wrapper_api = wrapper_api
@staticmethod
def create_cdm(wvd_path: str | None = None) -> Cdm:
@@ -103,18 +106,36 @@ class AppleMusicBaseInterface:
return response
@staticmethod
def format_cover(
template_cover_url: str,
cover_size: int,
cover_format: CoverFormat,
) -> str:
return re.sub(
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
f"/{cover_size}x{cover_size}bb.{cover_format.value}",
template_cover_url,
)
@classmethod
async def create(
cls,
apple_music_api: AppleMusicApi,
cover_format: CoverFormat = CoverFormat.JPG,
cover_size: int = 1200,
itunes_api: ItunesApi | None = None,
wvd_path: str | None = None,
itunes_api: ItunesApi | None = None,
wrapper_api: WrapperApi | None = None,
):
itunes_api = itunes_api or await ItunesApi.create(
storefront=apple_music_api.storefront,
language=apple_music_api.language,
**(
{"storefront_id": None}
if apple_music_api.storefront.lower() != "us"
else {}
),
)
cdm = cls.create_cdm(wvd_path)
@@ -124,6 +145,7 @@ class AppleMusicBaseInterface:
cover_format=cover_format,
cover_size=cover_size,
cdm=cdm,
wrapper_api=wrapper_api,
)
return base
@@ -203,12 +225,16 @@ class AppleMusicBaseInterface:
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"image/thumb/",
r"/\{w\}x\{h\}bb\.jpg",
"",
re.sub(
r"is1-ssl",
"a1",
cover_url_template,
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
cover_url_template,
),
),
)
@@ -246,10 +272,10 @@ class AppleMusicBaseInterface:
if self.cover_format == CoverFormat.RAW:
cover_url = template_url
else:
cover_url = re.sub(
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
f"/{self.cover_size}x{self.cover_size}bb.{self.cover_format.value}",
cover_url = self.format_cover(
template_url,
self.cover_size,
self.cover_format,
)
cover_file_extension = await self._get_cover_file_extension(cover_url)
@@ -307,3 +333,67 @@ class AppleMusicBaseInterface:
log.debug("success", playlist_tags=playlist_tags)
return playlist_tags
async def get_tags_from_asset_info(
self,
asset_data: dict,
lyrics: str | None = None,
use_album_date: bool = False,
) -> MediaTags:
log = logger.bind(
action="get_tags_from_asset_info", asset_id=asset_data["itemId"]
)
tags = MediaTags(
album=asset_data.get("playlistName"),
album_artist=asset_data.get("playlistArtistName"),
album_id=(
int(asset_data["playlistId"]) if asset_data.get("playlistId") else None
),
album_sort=asset_data.get("sort-album"),
artist=asset_data["artistName"],
artist_id=int(asset_data["artistId"]),
artist_sort=asset_data["sort-artist"],
comment=asset_data.get("comments"),
compilation=asset_data.get("compilation"),
composer=asset_data.get("composerName"),
composer_id=(
int(asset_data.get("composerId"))
if asset_data.get("composerId")
else None
),
composer_sort=asset_data.get("sort-composer"),
copyright=asset_data.get("copyright"),
date=(
await self.get_media_date(asset_data["playlistId"])
if use_album_date
else (
self.parse_date(asset_data["releaseDate"])
if asset_data.get("releaseDate")
else None
)
),
disc=asset_data.get("discNumber"),
disc_total=asset_data.get("discCount"),
gapless=asset_data.get("gapless"),
genre=asset_data.get("genre"),
genre_id=int(asset_data["genreId"]),
lyrics=lyrics if lyrics else None,
media_type=(
MediaType.SONG
if asset_data["kind"] == "song"
else MediaType.MUSIC_VIDEO
),
rating=MediaRating(asset_data["explicit"]),
storefront=asset_data["s"],
title=asset_data["itemName"],
title_id=int(asset_data["itemId"]),
title_sort=asset_data["sort-name"],
track=asset_data.get("trackNumber"),
track_total=asset_data.get("trackCount"),
xid=asset_data.get("xid"),
)
log.debug("success", tags=tags)
return tags
+7 -2
View File
@@ -11,8 +11,6 @@ MEDIA_RATING_STR_MAP = {
2: "Clean",
}
LEGACY_SONG_CODECS = {"aac-legacy", "aac-he-legacy"}
DRM_DEFAULT_KEY_MAPPING = {
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": (
"data:text/plain;base64,AAAAOHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABgSEAAAAAA"
@@ -96,3 +94,10 @@ ARTIST_AUTO_SELECT_STR_MAP = {
"top-songs": "Top Songs",
"music-videos": "Music Videos",
}
SONG_CODEC_FLAVOR_MAP = {
"aac-web": "28:ctrp256",
"aac-he-web": "32:ctrp64",
"aac-fps-web": "30:cbcp256",
"aac-he-fps-web": "34:cbcp64",
}
+17 -5
View File
@@ -4,9 +4,9 @@ from .constants import (
ARTIST_AUTO_SELECT_KEY_MAP,
ARTIST_AUTO_SELECT_STR_MAP,
FOURCC_MAP,
LEGACY_SONG_CODECS,
MEDIA_RATING_STR_MAP,
MEDIA_TYPE_STR_MAP,
SONG_CODEC_FLAVOR_MAP,
)
@@ -46,8 +46,11 @@ class MediaFileFormat(Enum):
class SongCodec(Enum):
AAC_LEGACY = "aac-legacy"
AAC_HE_LEGACY = "aac-he-legacy"
AAC_WEB = "aac-web"
AAC_HE_WEB = "aac-he-web"
# doesnt work with wrapper, gives ckc error
# AAC_FPS_WEB = "aac-fps-web"
# AAC_HE_FPS_WEB = "aac-he-fps-web"
AAC = "aac"
AAC_HE = "aac-he"
AAC_BINAURAL = "aac-binaural"
@@ -59,8 +62,17 @@ class SongCodec(Enum):
ALAC = "alac"
ASK = "ask"
def is_legacy(self) -> bool:
return self.value in LEGACY_SONG_CODECS
@property
def is_web(self) -> bool:
return self.value.endswith("-web")
@property
def flavor(self) -> str | None:
return SONG_CODEC_FLAVOR_MAP.get(self.value)
@property
def is_cenc(self) -> bool:
return self.flavor is not None and "ctrp" in self.flavor
class MusicVideoCodec(Enum):
+13 -2
View File
@@ -38,5 +38,16 @@ class GamdlInterfaceUrlParseError(GamdlInterfaceError):
class GamdlInterfaceArtistMediaTypeError(GamdlInterfaceError):
def __init__(self, media_type: str):
super().__init__(f"Artist has no media of type: {media_type}")
def __init__(self, media_id: str, media_type: str):
super().__init__(
f"Artist has no media of type (media ID: {media_id}): {media_type}"
)
class GamdlInterfaceFlatFilterExcludedError(GamdlInterfaceError):
def __init__(self, media_id: str, result: Any):
super().__init__(
f"Media excluded by flat filter (media ID: {media_id}): {result}"
)
self.result = result
+212 -352
View File
@@ -10,6 +10,7 @@ from .exceptions import (
GamdlInterfaceMediaNotAllowedError,
GamdlInterfaceUrlParseError,
GamdlInterfaceArtistMediaTypeError,
GamdlInterfaceFlatFilterExcludedError,
)
from .music_video import AppleMusicMusicVideoInterface
from .song import AppleMusicSongInterface
@@ -32,7 +33,7 @@ class AppleMusicInterface:
Callable[[ArtistMediaType, list[dict]], list[dict] | None] | None
) = None,
flat_filter_function: Callable[[dict], Any] | None = None,
concurrency: int = 5,
concurrency: int = 1,
disallowed_media_types: list[str] | None = None,
) -> None:
self.song = song
@@ -64,172 +65,128 @@ class AppleMusicInterface:
return url_match
async def _run_flat_filter(self, media: AppleMusicMedia) -> None:
if not self.flat_filter_function or not media.partial:
return
result = self.flat_filter_function(media.media_metadata)
if asyncio.iscoroutine(result):
result = await result
if result:
raise GamdlInterfaceFlatFilterExcludedError(media.media_id, result)
def _run_media_type_filter(self, media: AppleMusicMedia) -> None:
if not self.disallowed_media_types or not media.partial:
return
if media.media_metadata["type"] in self.disallowed_media_types:
raise GamdlInterfaceMediaNotAllowedError(
media.media_metadata["type"],
media.media_id,
)
async def _collect_generator(
self, generator_or_coroutine: AsyncGenerator[AppleMusicMedia, None]
) -> list[AppleMusicMedia]:
results = []
async for result in generator_or_coroutine:
results.append(result)
return results
async def _get_song_media(
self,
media_id: str | None = None,
media_id: str,
index: int | None = None,
total: int | None = None,
media_metadata: dict | None = None,
playlist_metadata: dict | None = None,
playlist_track: int | None = None,
) -> AppleMusicMedia:
if not media_metadata:
try:
media_metadata = (
await self.base.apple_music_api.get_song(
media_id,
)
)[
"data"
][0]
except Exception as e:
return AppleMusicMedia(
media_id=media_id,
media_metadata=None,
error=e,
)
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
)
if not media_id:
media_id = self.base.parse_catalog_media_id(media_metadata)
if index is not None:
media.index = index
if total is not None:
media.total = total
base_media = AppleMusicMedia(media_id, media_metadata)
if self.flat_filter_function:
flat_filter_result = self.flat_filter_function(media_metadata)
if asyncio.iscoroutine(flat_filter_result):
flat_filter_result = await flat_filter_result
if flat_filter_result:
base_media.flat_filter_result = flat_filter_result
return base_media
if (
self.disallowed_media_types
and base_media.media_metadata["type"] in self.disallowed_media_types
):
base_media.error = GamdlInterfaceMediaNotAllowedError(
base_media.media_metadata["type"],
media_id,
)
return base_media
media.media_metadata = media_metadata
media.playlist_metadata = playlist_metadata
try:
return await self.song.get_media(
media_metadata,
playlist_metadata,
playlist_track,
)
async for media in self.song.get_media(media):
yield media
self._run_media_type_filter(media)
await self._run_flat_filter(media)
except Exception as e:
base_media.error = e
return base_media
media.partial = False
media.error = e
yield media
return
async def _get_music_video_media(
self,
media_id: str | None = None,
media_id: str,
index: int | None = None,
total: int | None = None,
media_metadata: dict | None = None,
playlist_metadata: dict | None = None,
playlist_track: int | None = None,
) -> AppleMusicMedia:
if not media_metadata:
try:
media_metadata = (
await self.base.apple_music_api.get_music_video(
media_id,
)
)["data"][0]
except Exception as e:
return AppleMusicMedia(
media_id=media_id,
media_metadata=None,
error=e,
)
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
)
if not media_id:
media_id = self.music_video.parse_catalog_media_id(media_metadata)
if index is not None:
media.index = index
if total is not None:
media.total = total
base_media = AppleMusicMedia(media_id, media_metadata)
if self.flat_filter_function:
flat_filter_result = self.flat_filter_function(media_metadata)
if asyncio.iscoroutine(flat_filter_result):
flat_filter_result = await flat_filter_result
if flat_filter_result:
base_media.flat_filter_result = flat_filter_result
return base_media
if (
self.disallowed_media_types
and base_media.media_metadata["type"] in self.disallowed_media_types
):
base_media.error = GamdlInterfaceMediaNotAllowedError(
base_media.media_metadata["type"],
media_id,
)
return base_media
media.media_metadata = media_metadata
media.playlist_metadata = playlist_metadata
try:
return await self.music_video.get_media(
media_metadata,
playlist_metadata,
playlist_track,
)
async for media in self.music_video.get_media(media):
yield media
self._run_media_type_filter(media)
await self._run_flat_filter(media)
except Exception as e:
base_media.error = e
return base_media
media.partial = False
media.error = e
yield media
return
async def _get_uploaded_video_media(
self,
media_id: str,
) -> AppleMusicMedia:
try:
media_metadata = (
await self.base.apple_music_api.get_uploaded_video(
media_id,
)
)["data"][0]
except Exception as e:
return AppleMusicMedia(
media_id=media_id,
media_metadata=None,
error=e,
)
base_media = AppleMusicMedia(media_id, media_metadata)
if self.flat_filter_function:
flat_filter_result = self.flat_filter_function(media_metadata)
if asyncio.iscoroutine(flat_filter_result):
flat_filter_result = await flat_filter_result
if flat_filter_result:
base_media.flat_filter_result = flat_filter_result
return base_media
if (
self.disallowed_media_types
and base_media.media_metadata["type"] in self.disallowed_media_types
):
base_media.error = GamdlInterfaceMediaNotAllowedError(
base_media.media_metadata["type"],
media_id,
)
return base_media
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
)
try:
return await self.uploaded_video.get_media(media_metadata)
async for media in self.music_video.get_media(media):
yield
self._run_media_type_filter(media)
await self._run_flat_filter(media)
except Exception as e:
base_media.error = e
return base_media
media.partial = False
media.error = e
yield media
return
async def _get_album_media(
self,
media_id: str,
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
base_media = AppleMusicMedia(media_id)
try:
media_metadata = (
base_media.media_metadata = (
await self.base.apple_music_api.get_library_album(
media_id,
)
@@ -238,76 +195,57 @@ class AppleMusicInterface:
media_id,
)
)["data"][0]
self._run_media_type_filter(base_media)
await self._run_flat_filter(base_media)
except Exception as e:
yield AppleMusicMedia(
media_id=media_id,
media_metadata=None,
error=e,
)
base_media.partial = False
base_media.error = e
yield base_media
return
if self.flat_filter_function:
flat_filter_result = self.flat_filter_function(media_metadata)
yield base_media
if asyncio.iscoroutine(flat_filter_result):
flat_filter_result = await flat_filter_result
if flat_filter_result:
yield AppleMusicMedia(
media_id=media_id,
media_metadata=media_metadata,
flat_filter_result=flat_filter_result,
)
return
if (
self.disallowed_media_types
and media_metadata["type"] in self.disallowed_media_types
):
yield AppleMusicMedia(
media_id=media_id,
media_metadata=media_metadata,
error=GamdlInterfaceMediaNotAllowedError(
media_metadata["type"],
media_id,
),
)
return
tracks = media_metadata["relationships"]["tracks"]["data"]
tracks = base_media.media_metadata["relationships"]["tracks"]["data"]
tasks = [
(
self._get_song_media(
media_id=track["id"],
index=index,
total=base_media.media_metadata["attributes"]["trackCount"],
media_metadata=track,
playlist_metadata=media_metadata,
)
if track["type"] in {"songs", "library-songs"}
else self._get_music_video_media(
media_id=track["id"],
index=index,
total=base_media.media_metadata["attributes"]["trackCount"],
media_metadata=track,
playlist_metadata=media_metadata,
)
)
for track in tracks
for index, track in enumerate(tracks)
]
if self.concurrency == 1:
for task in tasks:
async for result in task:
yield result
async for media in task:
yield media
else:
for task in await safe_gather(*tasks, limit=self.concurrency):
yield task
collected_tasks = [self._collect_generator(task) for task in tasks]
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
for batch in batches:
for media in batch:
yield media
async def _get_playlist_media(
self,
media_id: str,
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
base_media = AppleMusicMedia(media_id)
try:
media_metadata = (
base_media.media_metadata = (
await self.base.apple_music_api.get_library_playlist(
media_id,
)
@@ -316,75 +254,42 @@ class AppleMusicInterface:
media_id,
)
)["data"][0]
except Exception as e:
yield AppleMusicMedia(
media_id=media_id,
media_metadata=None,
error=e,
)
return
if self.flat_filter_function:
flat_filter_result = self.flat_filter_function(media_metadata)
self._run_media_type_filter(base_media)
await self._run_flat_filter(base_media)
if asyncio.iscoroutine(flat_filter_result):
flat_filter_result = await flat_filter_result
if flat_filter_result:
yield AppleMusicMedia(
media_id=media_id,
media_metadata=media_metadata,
flat_filter_result=flat_filter_result,
)
return
if (
self.disallowed_media_types
and media_metadata["type"] in self.disallowed_media_types
):
yield AppleMusicMedia(
media_id=media_id,
media_metadata=media_metadata,
error=GamdlInterfaceMediaNotAllowedError(
media_metadata["type"],
media_id,
),
)
return
tracks = media_metadata["relationships"]["tracks"]["data"]
next_uri = media_metadata["relationships"]["tracks"].get("next")
href_uri = media_metadata["relationships"]["tracks"].get("href")
while next_uri:
try:
tracks = base_media.media_metadata["relationships"]["tracks"]["data"]
next_uri = base_media.media_metadata["relationships"]["tracks"].get("next")
href_uri = base_media.media_metadata["relationships"]["tracks"].get("href")
while next_uri:
extended_data = await self.base.apple_music_api.get_extended_api_data(
next_uri,
href_uri,
)
except Exception as e:
yield AppleMusicMedia(
media_id=media_id,
media_metadata=media_metadata,
error=e,
)
return
tracks.extend(extended_data["data"])
next_uri = extended_data.get("next")
tracks.extend(extended_data["data"])
next_uri = extended_data.get("next")
except Exception as e:
base_media.partial = False
base_media.error = e
yield base_media
return
yield base_media
tasks = [
(
self._get_song_media(
media_id=track["id"],
index=index,
media_metadata=track,
playlist_metadata=media_metadata,
playlist_track=index + 1,
playlist_metadata=base_media.media_metadata,
)
if track["type"] in {"songs", "library-songs"}
else self._get_music_video_media(
media_id=track["id"],
index=index,
media_metadata=track,
playlist_metadata=media_metadata,
playlist_track=index + 1,
playlist_metadata=base_media.media_metadata,
)
)
for index, track in enumerate(tracks)
@@ -392,25 +297,62 @@ class AppleMusicInterface:
if self.concurrency == 1:
for task in tasks:
async for result in task:
yield result
async for media in task:
yield media
else:
for task in await safe_gather(*tasks, limit=self.concurrency):
yield task
collected_tasks = [self._collect_generator(task) for task in tasks]
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
for batch in batches:
for media in batch:
yield media
async def _get_artist_media(
self,
media_id: str,
) -> AsyncGenerator[AppleMusicMedia, None]:
base_media = AppleMusicMedia(media_id)
try:
media_metadata = (
base_media.media_metadata = (
await self.base.apple_music_api.get_artist(
media_id,
)
)[
"data"
][0]
)["data"][0]
self._run_media_type_filter(base_media)
await self._run_flat_filter(base_media)
if self.artist_select_media_type_function:
artist_media_type = self.artist_select_media_type_function(
list(ArtistMediaType),
base_media.media_metadata,
)
if asyncio.iscoroutine(artist_media_type):
artist_media_type = await artist_media_type
else:
artist_media_type = list(ArtistMediaType)[0]
relation_key, type_key = artist_media_type.path_key
items_relation = base_media.media_metadata.get(relation_key, {}).get(
type_key, {}
)
items = items_relation.get("data", [])
if not items:
raise GamdlInterfaceArtistMediaTypeError(
base_media.media_id,
str(artist_media_type),
)
next_uri = items_relation.get("next")
href_uri = items_relation.get("href")
while next_uri:
extended_data = await self.base.apple_music_api.get_extended_api_data(
next_uri,
href_uri,
)
items.extend(extended_data.get("data", []))
next_uri = extended_data.get("next")
except Exception as e:
yield AppleMusicMedia(
media_id=media_id,
@@ -419,73 +361,7 @@ class AppleMusicInterface:
)
return
if self.flat_filter_function:
flat_filter_result = self.flat_filter_function(media_metadata)
if asyncio.iscoroutine(flat_filter_result):
flat_filter_result = await flat_filter_result
if flat_filter_result:
yield AppleMusicMedia(
media_id=media_id,
media_metadata=media_metadata,
flat_filter_result=flat_filter_result,
)
return
if (
self.disallowed_media_types
and media_metadata["type"] in self.disallowed_media_types
):
yield AppleMusicMedia(
media_id=media_id,
media_metadata=media_metadata,
error=GamdlInterfaceMediaNotAllowedError(
media_metadata["type"],
media_id,
),
)
return
if self.artist_select_media_type_function:
artist_media_type = self.artist_select_media_type_function(
list(ArtistMediaType),
media_metadata,
)
if asyncio.iscoroutine(artist_media_type):
artist_media_type = await artist_media_type
else:
artist_media_type = list(ArtistMediaType)[0]
relation_key, type_key = artist_media_type.path_key
items_relation = media_metadata.get(relation_key, {}).get(type_key, {})
items = items_relation.get("data", [])
if not items:
yield AppleMusicMedia(
media_id=media_id,
media_metadata=media_metadata,
error=GamdlInterfaceArtistMediaTypeError(str(artist_media_type)),
)
return
next_uri = items_relation.get("next")
href_uri = items_relation.get("href")
while next_uri:
try:
extended_data = await self.base.apple_music_api.get_extended_api_data(
next_uri,
href_uri,
)
except Exception as e:
yield AppleMusicMedia(
media_id=media_id,
media_metadata=media_metadata,
error=e,
)
return
items.extend(extended_data.get("data", []))
next_uri = extended_data.get("next")
yield base_media
if self.artist_select_items_function:
selected_items = self.artist_select_items_function(
@@ -498,60 +374,40 @@ class AppleMusicInterface:
selected_items = items[:1]
tasks = []
for item in selected_items:
for index, item in enumerate(selected_items):
if item["type"] in {"songs", "library-songs"}:
tasks.append(
(
item["type"],
self._get_song_media(
media_id=item["id"],
media_metadata=item,
),
self._get_song_media(
media_id=item["id"],
index=index,
total=len(selected_items),
media_metadata=item,
)
)
elif item["type"] in {"albums", "library-albums"}:
tasks.append(
(
item["type"],
self._get_album_media(
media_id=item["id"],
),
self._get_album_media(
media_id=item["id"],
)
)
else:
tasks.append(
(
item["type"],
self._get_music_video_media(
media_id=item["id"],
media_metadata=item,
),
self._get_music_video_media(
media_id=item["id"],
index=index,
total=len(selected_items),
media_metadata=item,
)
)
if self.concurrency == 1:
for item_type, task in tasks:
if item_type in {"albums", "library-albums"}:
async for result in task:
yield result
else:
yield await task
for task in tasks:
async for media in task:
yield media
else:
async def _collect_generator(generator_or_coroutine, item_type):
if item_type in {"albums", "library-albums"}:
results = []
async for result in generator_or_coroutine:
results.append(result)
return results
else:
return [await generator_or_coroutine]
collected_tasks = [
_collect_generator(task, item_type) for item_type, task in tasks
]
for batch in await safe_gather(*collected_tasks, limit=self.concurrency):
collected_tasks = [self._collect_generator(task) for task in tasks]
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
for batch in batches:
for media in batch:
yield media
@@ -570,16 +426,20 @@ class AppleMusicInterface:
)
if url_info.type == "song" or url_info.sub_id:
media = await self._get_song_media(
async for media in self._get_song_media(
media_id=url_info.sub_id or url_info.id,
)
yield media
index=0,
total=1,
):
yield media
elif url_info.type == "music-video":
media = await self._get_music_video_media(
async for media in self._get_music_video_media(
media_id=url_info.id,
)
yield media
index=0,
total=1,
):
yield media
elif url_info.type == "album" or url_info.library_type == "albums":
async for media in self._get_album_media(
@@ -596,10 +456,10 @@ class AppleMusicInterface:
yield media
elif url_info.type == "post":
media = await self._get_uploaded_video_media(
async for media in self._get_uploaded_video_media(
media_id=url_info.id,
)
yield media
):
yield media
elif url_info.type == "artist":
async for media in self._get_artist_media(
+44 -22
View File
@@ -1,6 +1,6 @@
import asyncio
import urllib.parse
from typing import Callable
from typing import AsyncGenerator, Callable
import m3u8
import structlog
@@ -64,6 +64,8 @@ class AppleMusicMusicVideoInterface:
self,
itunes_page_metadata: dict,
) -> str | None:
log = logger.bind(action="get_m3u8_master_url_from_itunes_page_metadata")
stream_url = itunes_page_metadata["offers"][0]["assets"][0].get("hlsUrl")
if not stream_url:
return None
@@ -76,6 +78,16 @@ class AppleMusicMusicVideoInterface:
query=urllib.parse.urlencode(query, doseq=True)
).geturl()
m3u8_master_url = m3u8_master_url.replace(
"play-edge.itunes.apple.com",
"play.itunes.apple.com",
).replace(
"MZPlayLocal.woa",
"MZPlay.woa",
)
log.debug("success", m3u8_master_url=m3u8_master_url)
return m3u8_master_url
async def get_tags(
@@ -378,35 +390,43 @@ class AppleMusicMusicVideoInterface:
async def get_media(
self,
music_video_metadata: dict,
playlist_metadata: dict | None = None,
playlist_track: dict | None = None,
) -> AppleMusicMedia:
media = AppleMusicMedia(
media_id=self.base.parse_catalog_media_id(music_video_metadata),
media_metadata=music_video_metadata,
)
media: AppleMusicMedia,
) -> AsyncGenerator[AppleMusicMedia, None]:
if not media.media_metadata:
media.media_metadata = (
await self.base.apple_music_api.get_music_video(media.media_id)
)["data"][0]
if not self.base.is_media_streamable(music_video_metadata):
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
yield media
if not self.base.is_media_streamable(media.media_metadata):
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
if playlist_metadata and playlist_track:
media.playlist_metadata = playlist_metadata
if media.playlist_metadata:
media.playlist_tags = self.base.get_playlist_tags(
playlist_metadata,
playlist_track,
media.playlist_metadata,
media.index,
)
media.cover = await self.base.get_cover(music_video_metadata)
media.cover = await self.base.get_cover(media.media_metadata)
itunes_page_metadata = await self.get_itunes_page_metadata(music_video_metadata)
media.tags = await self.get_tags(
music_video_metadata,
itunes_page_metadata,
)
itunes_page_metadata = await self.get_itunes_page_metadata(media.media_metadata)
if self.base.wrapper_api:
playback = await self.base.wrapper_api.get_playback(media.media_id)
media.tags = await self.base.get_tags_from_asset_info(
playback["songList"][0]["assets"][0]["metadata"],
)
else:
media.tags = await self.get_tags(
media.media_metadata,
itunes_page_metadata,
)
media.stream_info = await self.get_stream_info(
music_video_metadata,
media.media_metadata,
itunes_page_metadata,
)
if not media.stream_info:
@@ -423,4 +443,6 @@ class AppleMusicMusicVideoInterface:
media.decryption_key = await self.get_decryption_key(media.stream_info)
return media
media.partial = False
yield media
+571 -521
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -122,7 +122,8 @@ class StreamInfo:
codec: str = None
width: int = None
height: int = None
legacy: bool = None
use_cenc: bool = False
use_single_content_key: bool = True
@dataclass
@@ -155,7 +156,10 @@ class Cover:
@dataclass
class AppleMusicMedia:
media_id: str
media_metadata: dict
index: int = 0
total: int = 0
partial: bool = True
media_metadata: dict | None = None
error: BaseException | None = None
playlist_metadata: dict | None = None
playlist_tags: PlaylistTags | None = None
@@ -165,7 +169,6 @@ class AppleMusicMedia:
tags: MediaTags | None = None
stream_info: StreamInfoAv | None = None
decryption_key: DecryptionKeyAv | None = None
flat_filter_result: Any = None
@dataclass
+18 -11
View File
@@ -1,5 +1,6 @@
import asyncio
from collections.abc import Callable
from typing import AsyncGenerator
import structlog
@@ -105,22 +106,28 @@ class AppleMusicUploadedVideoInterface:
async def get_media(
self,
uploaded_video_metadata: dict,
) -> AppleMusicMedia:
media = AppleMusicMedia(
uploaded_video_metadata["id"],
uploaded_video_metadata,
)
media: AppleMusicMedia,
) -> AsyncGenerator[AppleMusicMedia, None]:
if not media.media_metadata:
media.media_metadata = (
await self.base.apple_music_api.get_uploaded_video(media.media_id)
)["data"][0]
if not self.base.is_media_streamable(uploaded_video_metadata):
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
yield media
if not self.base.is_media_streamable(media.media_metadata):
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
media.cover = await self.base.get_cover(uploaded_video_metadata)
media.cover = await self.base.get_cover(media.media_metadata)
media.stream_info = await self.get_stream_info(uploaded_video_metadata)
media.stream_info = await self.get_stream_info(media.media_metadata)
if not media.stream_info:
raise GamdlInterfaceFormatNotAvailableError(media.media_id)
media.tags = self.get_tags(uploaded_video_metadata)
media.tags = self.get_tags(media.media_metadata)
return media
media.partial = False
yield media
+14 -5
View File
@@ -1,14 +1,13 @@
import asyncio
import string
import subprocess
import typing
async def async_subprocess(*args: str, silent: bool = False) -> None:
if silent:
additional_args = {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.PIPE,
}
else:
additional_args = {}
@@ -17,10 +16,20 @@ async def async_subprocess(*args: str, silent: bool = False) -> None:
*args,
**additional_args,
)
await proc.communicate()
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
raise Exception(f'"{args[0]}" exited with code {proc.returncode}')
msg = (
f"Exited with code {proc.returncode}: {' '.join(str(arg) for arg in args)}"
)
if stdout:
msg += f"\nstdout:\n{stdout.decode()}"
if stderr:
msg += f"\nstderr:\n{stderr.decode()}"
raise Exception(msg)
async def safe_gather(
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gamdl"
version = "3.0"
version = "3.6"
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
readme = "README.md"
license = "MIT"
Generated
+1 -1
View File
@@ -223,7 +223,7 @@ wheels = [
[[package]]
name = "gamdl"
version = "3.0"
version = "3.6"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },