Compare commits

..

127 Commits

Author SHA1 Message Date
glomatico b0c5335767 Bump version to 3.7.4 in __init__.py, pyproject.toml, and uv.lock 2026-06-12 20:51:52 -03:00
glomatico 69c2a8a063 Refactor GamdlApiResponseError to accept Any type for content and improve message formatting 2026-06-12 20:51:11 -03:00
Rafael Moraes fb143ad1b4 Merge pull request #315 from nirbhaykulkarni/fix/token-extraction-and-cover-timeout
Fix token extraction and cover art timeout
2026-06-12 20:46:11 -03:00
nirbhaykulkarni b66c06a9cb Fix token extraction and cover art timeout
- Search non-legacy index JS bundle for token (Apple moved it from index-legacy)
- Broaden JWT regex from eyJh to full 3-part JWT pattern (tokens now start with eyJ0)
- Add 30s timeout and follow_redirects to cover art fetch to avoid ConnectTimeout
2026-06-12 21:39:18 +05:30
glomatico a9e75384f0 Add method to switch m3u8 master URL to default and update playback handling 2026-06-05 22:07:31 -03:00
Rafael Moraes d88dbe5bb6 Bump version to 3.7.3 2026-05-28 17:28:47 -03:00
Rafael Moraes 8398d9c65f Handle missing playParams in metadata 2026-05-28 17:28:22 -03:00
Rafael Moraes c6bce4b2c1 Bump version to 3.7.2 2026-05-28 17:24:21 -03:00
Rafael Moraes f54ab12408 Guard playParams access to avoid KeyError 2026-05-28 17:23:35 -03:00
Rafael Moraes 817479d807 Use uncensored names and add sort fields 2026-05-28 17:20:44 -03:00
Rafael Moraes d072f322db Remove mp4.clear() call in AppleMusic downloader 2026-05-24 14:53:48 -03:00
Rafael Moraes a62ac76639 Rename SONG_CODEC_FLAVOR_MAP to MEDIA_CODEC_FLAVOR_MAP 2026-05-24 14:39:23 -03:00
Rafael Moraes 31b143d870 Add debug logging for m3u8 master URL 2026-05-24 14:38:32 -03:00
Rafael Moraes 387861bb2f Support file-backed samples and streaming decrypt 2026-05-24 14:21:21 -03:00
Rafael Moraes 24fb9bddb9 Make MusicVideoCodec.fourcc a property 2026-05-24 14:12:46 -03:00
Rafael Moraes 30ca108b80 Return optional fourcc for MusicVideoCodec 2026-05-24 12:59:38 -03:00
Rafael Moraes 1d00e74ec6 Use yt-dlp HlsFD/HttpFD and handle failures 2026-05-24 12:57:13 -03:00
Rafael Moraes bb511de552 Use download_stream instead of _download_ytdlp_async 2026-05-24 12:47:45 -03:00
Rafael Moraes 15c1bc64dd Make MusicVideoCodec.fourcc a property 2026-05-24 11:49:37 -03:00
Rafael Moraes 4f910c8e8a Use 'codec' key instead of 'formats' in error 2026-05-23 23:02:23 -03:00
Rafael Moraes ff3dcda54c Bump version to 3.7.1 2026-05-23 23:01:20 -03:00
Rafael Moraes 7ac3322839 Handle missing webplayback in song stream info 2026-05-23 22:59:00 -03:00
Rafael Moraes 740cad2ee0 Refactor song interface stream logic and imports 2026-05-23 22:57:28 -03:00
Rafael Moraes 5a41dfbdaa Handle missing m3u8 master URL 2026-05-23 22:54:48 -03:00
Rafael Moraes 141d9cd654 Pass codec through music video stream selection 2026-05-23 22:53:43 -03:00
Rafael Moraes 50f82b5de2 Refactor music video stream fetching 2026-05-23 22:41:28 -03:00
Rafael Moraes eb9caff85c Await get_tags_from_asset_info call 2026-05-23 22:35:05 -03:00
Rafael Moraes 73e0b4b48d Mark uploaded Apple Music video as DRM-free 2026-05-23 16:05:37 -03:00
Rafael Moraes 8f82697c14 Bump package version to 3.7 2026-05-23 16:04:25 -03:00
Rafael Moraes 4650391be3 Add FFmpeg requirement and --ffmpeg-path option to README 2026-05-23 16:02:26 -03:00
Rafael Moraes 0519adf693 Clarify supported URL types in README 2026-05-23 15:59:41 -03:00
Rafael Moraes 4fc91bac9f Add get_m3u8_master_url helper and use it 2026-05-23 15:58:20 -03:00
Rafael Moraes cb367049f1 Remove get_tags method from AppleMusicSongInterface 2026-05-23 15:56:42 -03:00
Rafael Moraes 34357ad31e Handle library music videos and fix logging id 2026-05-23 15:54:13 -03:00
Rafael Moraes a7140cb860 Use .get for playParams isLibrary checks 2026-05-23 15:50:24 -03:00
Rafael Moraes aa14693924 Add drm_free and is_library flags to types 2026-05-23 15:47:49 -03:00
Rafael Moraes 76a7c792cd Use API response 'id' for media.media_id 2026-05-23 15:47:39 -03:00
Rafael Moraes c75249bc2d Support Apple Music library songs streaming 2026-05-23 15:47:29 -03:00
Rafael Moraes 001a502a5c Support Apple Music library items 2026-05-23 15:47:12 -03:00
Rafael Moraes 1eba432153 Handle DRM-free tracks in AppleMusic downloader 2026-05-23 15:45:09 -03:00
Rafael Moraes 622661a679 Support songs/music-videos in library URL regex 2026-05-23 15:44:58 -03:00
Rafael Moraes 8200ee0dd1 Refactor AppleMusicBaseInterface metadata parsing 2026-05-23 15:44:48 -03:00
Rafael Moraes a8bf884d8f Handle m3u8 and HttpFD downloads in ytdlp 2026-05-23 15:44:23 -03:00
Rafael Moraes 6d8ecf65b6 Support library tracks in get_webplayback 2026-05-23 15:44:12 -03:00
Rafael Moraes 03fb4a255e Add library song/video APIs and params 2026-05-23 14:42:55 -03:00
Rafael Moraes f8ec2367af Add include param to library endpoints 2026-05-23 14:18:16 -03:00
Rafael Moraes b5432d1344 Add library endpoints and client methods 2026-05-23 13:37:00 -03:00
Rafael Moraes bd59bb7c98 Add ffmpeg_path CLI option and pass to downloader 2026-05-23 12:57:07 -03:00
Rafael Moraes 92b8220c71 Add ffmpeg path option to downloader 2026-05-23 12:56:54 -03:00
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
34 changed files with 4373 additions and 2126 deletions
+97 -106
View File
@@ -23,31 +23,42 @@ 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`.
N_m3u8DL-RE also needs FFmpeg. If the FFmpeg executable is not available in your system PATH, set its location with `--ffmpeg-path` or `ffmpeg_path`.
## 📦 Installation
@@ -61,9 +72,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
@@ -73,10 +83,10 @@ gamdl [OPTIONS] URLS...
### Supported URL Types
- Songs
- Albums (Public/Library)
- Playlists (Public/Library)
- Music Videos
- Songs (Catalog/Library)
- Albums (Catalog/Library)
- Playlists (Catalog/Library)
- Music Videos (Catalog/Library)
- Artists
- Post Videos
- Apple Music Classical
@@ -123,65 +133,60 @@ 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` |
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
| `--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 +217,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 +232,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 +247,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 +286,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 +365,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.7.4"
+1
View File
@@ -1,3 +1,4 @@
from .apple_music import AppleMusicApi
from .exceptions import *
from .itunes import ItunesApi
from .wrapper import WrapperApi
+182 -32
View File
@@ -15,15 +15,22 @@ from .constants import (
APPLE_MUSIC_HOMEPAGE_URL,
APPLE_MUSIC_LIBRARY_ALBUM_API_URI,
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI,
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI,
APPLE_MUSIC_LICENSE_API_URL,
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI,
APPLE_MUSIC_MUSIC_VIDEO_API_URI,
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI,
APPLE_MUSIC_PLAYLIST_API_URI,
APPLE_MUSIC_SEARCH_API_URI,
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI,
APPLE_MUSIC_LIBRARY_SONG_API_URI,
APPLE_MUSIC_LIBRARY_SONGS_API_URI,
APPLE_MUSIC_SONG_API_URI,
APPLE_MUSIC_UPLOADED_VIDEO_API_URL,
APPLE_MUSIC_WEBPLAYBACK_API_URL,
)
from .exceptions import GamdlApiResponseError
from .wrapper import WrapperApi
logger = structlog.get_logger(__name__)
@@ -70,6 +77,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,11 +89,11 @@ 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(
r"/(assets/index-legacy[~-][^/\"]+\.js)",
r"/(assets/index[~-][^/\"]+\.js)",
home_page,
)
if not index_js_uri_match:
@@ -94,6 +102,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,10 +113,10 @@ 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)
token_match = re.search(r'"(eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+)"', index_js_page)
if not token_match:
raise GamdlApiResponseError("Error finding token in index.js page")
token = token_match.group(1)
@@ -124,6 +133,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 +152,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 +249,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 +274,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 +285,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:
@@ -423,9 +432,54 @@ class AppleMusicApi:
return artist
async def get_library_song(
self,
song_id: str,
include: str = "catalog",
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_song", song_id=song_id)
song = await self._amp_request(
APPLE_MUSIC_LIBRARY_SONG_API_URI.format(
song_id=song_id,
),
{
"include": include,
"extend": extend,
},
)
log.debug("success", song=song)
return song
async def get_library_music_video(
self,
music_video_id: str,
include: str = "catalog",
) -> dict:
log = logger.bind(
action="get_library_music_video", music_video_id=music_video_id
)
music_video = await self._amp_request(
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI.format(
music_video_id=music_video_id,
),
{
"include": include,
},
)
log.debug("success", music_video=music_video)
return music_video
async def get_library_album(
self,
album_id: str,
include: str = "catalog",
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_album", album_id=album_id)
@@ -435,6 +489,7 @@ class AppleMusicApi:
album_id=album_id,
),
{
"include": include,
"extend": extend,
},
)
@@ -446,7 +501,7 @@ class AppleMusicApi:
async def get_library_playlist(
self,
playlist_id: str,
include: str = "tracks",
include: str = "catalog,tracks",
limit: int = 100,
extend: str = "extendedAssetUrls",
) -> dict:
@@ -467,6 +522,92 @@ class AppleMusicApi:
return playlist
async def get_library_songs(
self,
limit: int = 100,
offset: int = 0,
include: str = "catalog",
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_songs")
library_songs = await self._amp_request(
APPLE_MUSIC_LIBRARY_SONGS_API_URI,
{
"limit": limit,
"offset": offset,
"include": include,
"extend": extend,
},
)
log.debug("success", library_songs=library_songs)
return library_songs
async def get_library_music_videos(
self,
limit: int = 100,
offset: int = 0,
include: str = "catalog",
) -> dict:
log = logger.bind(action="get_library_music_videos")
library_music_videos = await self._amp_request(
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI,
{
"limit": limit,
"offset": offset,
"include": include,
},
)
log.debug("success", library_music_videos=library_music_videos)
return library_music_videos
async def get_library_albums(
self,
limit: int = 100,
offset: int = 0,
include: str = "catalog",
) -> dict:
log = logger.bind(action="get_library_albums")
library_albums = await self._amp_request(
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI,
{
"limit": limit,
"offset": offset,
"include": include,
},
)
log.debug("success", library_albums=library_albums)
return library_albums
async def get_library_playlists(
self,
limit: int = 100,
offset: int = 0,
include: str = "catalog",
) -> dict:
log = logger.bind(action="get_library_playlists")
library_playlists = await self._amp_request(
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI,
{
"limit": limit,
"offset": offset,
"include": include,
},
)
log.debug("success", library_playlists=library_playlists)
return library_playlists
async def get_search_results(
self,
term: str,
@@ -513,13 +654,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"]},
},
)
@@ -530,24 +669,34 @@ class AppleMusicApi:
async def get_webplayback(
self,
track_id: str,
is_library: bool = False,
) -> dict:
log = logger.bind(action="get_webplayback", track_id=track_id)
response = None
if is_library:
request_body = {
"universalLibraryId": track_id,
}
else:
request_body = {
"salableAdamId": track_id,
}
request_body["language"] = self.language
try:
response = await self.client.post(
APPLE_MUSIC_WEBPLAYBACK_API_URL,
json={
"salableAdamId": track_id,
"language": self.language,
},
json=request_body,
)
response.raise_for_status()
webplayback = response.json()
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 +719,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 +737,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:
+8
View File
@@ -14,9 +14,17 @@ APPLE_MUSIC_UPLOADED_VIDEO_API_URL = (
APPLE_MUSIC_ALBUM_API_URI = "/v1/catalog/{storefront}/albums/{album_id}"
APPLE_MUSIC_PLAYLIST_API_URI = "/v1/catalog/{storefront}/playlists/{playlist_id}"
APPLE_MUSIC_ARTIST_API_URI = "/v1/catalog/{storefront}/artists/{artist_id}"
APPLE_MUSIC_LIBRARY_SONG_API_URI = "/v1/me/library/songs/{song_id}"
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI = (
"/v1/me/library/music-videos/{music_video_id}"
)
APPLE_MUSIC_LIBRARY_ALBUM_API_URI = "/v1/me/library/albums/{album_id}"
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI = "/v1/me/library/playlists/{playlist_id}"
APPLE_MUSIC_SEARCH_API_URI = "/v1/catalog/{storefront}/search"
APPLE_MUSIC_LIBRARY_SONGS_API_URI = "/v1/me/library/songs"
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI = "/v1/me/library/music-videos"
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI = "/v1/me/library/albums"
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI = "/v1/me/library/playlists"
APPLE_MUSIC_WEBPLAYBACK_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
+15 -3
View File
@@ -1,3 +1,6 @@
import json
from typing import Any
from ..utils import GamdlError
@@ -9,7 +12,7 @@ class GamdlApiResponseError(GamdlApiError):
def __init__(
self,
message: str,
content: str,
content: Any | None = None,
status_code: int | None = None,
):
self.message = message
@@ -19,7 +22,16 @@ class GamdlApiResponseError(GamdlApiError):
if status_code is not None:
message = f"{message} (Status code: {status_code})"
if content:
message += f": {content}"
if content is not None:
if isinstance(content, str):
content_text = content
else:
try:
content_text = json.dumps(content)
except TypeError:
content_text = str(content)
if content_text:
message += f": {content_text}"
super().__init__(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 -91
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,7 @@ 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 +196,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 +235,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 -54
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,14 +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(
@@ -320,30 +321,6 @@ class CliConfig:
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 +422,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
+103 -36
View File
@@ -1,11 +1,16 @@
import asyncio
import multiprocessing
import queue
import re
import shutil
import traceback
from pathlib import Path
import structlog
from mutagen.mp4 import MP4, MP4Cover
from yt_dlp import YoutubeDL
from yt_dlp.downloader.hls import HlsFD
from yt_dlp.downloader.http import HttpFD
from ..interface.enums import CoverFormat
from ..interface.interface import AppleMusicInterface
@@ -17,6 +22,51 @@ 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:
Path(download_path).parent.mkdir(parents=True, exist_ok=True)
with YoutubeDL(
{
"quiet": True,
"no_warnings": True,
"overwrites": True,
"noprogress": silent,
"allow_unplayable_formats": True,
"concurrent_fragment_downloads": 8,
}
) as ydl:
if stream_url.split("?")[0].endswith(".m3u8"):
hls_downloader = HlsFD(ydl, ydl.params)
success, _ = hls_downloader.download(
download_path,
{
"url": stream_url,
"ext": "mp4",
"protocol": "m3u8",
},
)
if not success:
raise RuntimeError("yt-dlp HLS download failed")
else:
http_downloader = HttpFD(ydl, ydl.params)
success, _ = http_downloader.download(
download_path,
{
"url": stream_url,
},
)
if not success:
raise RuntimeError("yt-dlp HTTP download failed")
except Exception as e:
result_queue.put(("error", repr(e), traceback.format_exc()))
class AppleMusicBaseDownloader:
def __init__(
self,
@@ -24,11 +74,7 @@ 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 +93,7 @@ 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 +114,12 @@ 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(
@@ -202,40 +240,70 @@ class AppleMusicBaseDownloader:
return final_path
async def download_stream(self, stream_url: str, download_path: str):
async def download_stream(
self,
stream_url: str,
download_path: str,
):
log = logger.bind(
action="download_stream", stream_url=stream_url, download_path=download_path
)
if self.download_mode == DownloadMode.YTDLP:
await self._download_ytdlp_async(stream_url, download_path)
stream_url_stripped = stream_url.split("?")[0]
if self.download_mode == DownloadMode.NM3U8DLRE:
if (
self.download_mode == DownloadMode.YTDLP
or not stream_url_stripped.endswith(".m3u8")
):
await self._download_ytdlp_async(
stream_url,
download_path,
)
elif self.download_mode == DownloadMode.NM3U8DLRE:
await self._download_nm3u8dlre(stream_url, download_path)
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,
async def _download_ytdlp_async(
self,
stream_url: str,
download_path: str,
) -> None:
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)
@@ -298,7 +366,6 @@ class AppleMusicBaseDownloader:
skip_tagging: bool,
):
mp4 = MP4(media_path)
mp4.clear()
if not skip_tagging:
if cover_bytes is not None:
+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 = (
+200 -181
View File
@@ -1,181 +1,200 @@
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:
if download_item.media.stream_info.audio_track.drm_free:
await self.base.download_stream(
download_item.media.stream_info.audio_track.stream_url,
download_item.staged_path,
)
else:
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
+1 -1
View File
@@ -46,7 +46,7 @@ class AppleMusicUploadedVideoDownloader:
self,
download_item: DownloadItem,
) -> None:
await self.base._download_ytdlp_async(
await self.base.download_stream(
download_item.media.stream_info.video_track.stream_url,
download_item.staged_path,
)
+114 -19
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:
@@ -53,11 +56,6 @@ class AppleMusicBaseInterface:
) -> bool:
return bool(media_metadata["attributes"].get("playParams"))
@staticmethod
def parse_catalog_media_id(media_metadata: dict) -> str:
play_params = media_metadata["attributes"].get("playParams", {})
return play_params.get("catalogId", media_metadata["id"])
@staticmethod
def parse_media_id_from_url(media_metadata: dict) -> str | None:
media_url = media_metadata["attributes"].get("url")
@@ -103,18 +101,44 @@ 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,
)
@staticmethod
def get_catalog_metadata_from_library(library_metadata: dict) -> dict | None:
data = library_metadata.get("relationships", {}).get("catalog", {}).get("data")
if not data:
return None
return data[0]
@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 +148,7 @@ class AppleMusicBaseInterface:
cover_format=cover_format,
cover_size=cover_size,
cdm=cdm,
wrapper_api=wrapper_api,
)
return base
@@ -180,8 +205,8 @@ class AppleMusicBaseInterface:
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
log = logger.bind(action="get_cover_bytes", cover_url=cover_url)
async with httpx.AsyncClient() as client:
response = await client.get(cover_url)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(cover_url, follow_redirects=True)
if response.status_code == 404:
log.debug("cover_not_found")
@@ -203,12 +228,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,
),
),
)
@@ -237,19 +266,17 @@ class AppleMusicBaseInterface:
self,
metadata: dict,
) -> str:
log = logger.bind(
action="get_cover", media_id=self.parse_catalog_media_id(metadata)
)
log = logger.bind(action="get_cover", media_id=metadata["id"])
template_url = self._get_cover_template_url(metadata)
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 +334,71 @@ 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"]) if asset_data.get("artistId") else None
),
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"]) if asset_data.get("genreId") else None
),
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=(int(asset_data["s"]) if asset_data.get("s") else None),
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
+9 -4
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"
@@ -73,8 +71,8 @@ VALID_URL_PATTERN = re.compile(
r"(?:\?i=(?P<sub_id>[0-9]+))?"
r"|"
r"(?:/(?P<library_storefront>[a-z]{2}))?"
r"/library/(?P<library_type>playlist|albums)"
r"/(?P<library_id>p\.[a-zA-Z0-9]+|l\.[a-zA-Z0-9]+)"
r"/library/(?P<library_type>playlist|albums|songs|music-videos)"
r"/(?P<library_id>[pli]\.[a-zA-Z0-9]+)"
r")"
)
@@ -96,3 +94,10 @@ ARTIST_AUTO_SELECT_STR_MAP = {
"top-songs": "Top Songs",
"music-videos": "Music Videos",
}
MEDIA_CODEC_FLAVOR_MAP = {
"aac-web": "28:ctrp256",
"aac-he-web": "32:ctrp64",
"aac-fps-web": "30:cbcp256",
"aac-he-fps-web": "34:cbcp64",
}
+20 -7
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,
MEDIA_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 MEDIA_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):
@@ -68,8 +80,9 @@ class MusicVideoCodec(Enum):
H265 = "h265"
ASK = "ask"
def fourcc(self) -> str:
return FOURCC_MAP[self.value]
@property
def fourcc(self) -> str | None:
return FOURCC_MAP.get(self.value)
class MusicVideoResolution(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
+279 -407
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,327 +65,164 @@ 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,
)
if not media_id:
media_id = self.base.parse_catalog_media_id(media_metadata)
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
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
is_library=is_library,
index=index,
total=total,
media_metadata=media_metadata,
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,
)
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
is_library=is_library,
index=index,
total=total,
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
)
if not media_id:
media_id = self.music_video.parse_catalog_media_id(media_metadata)
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 = (
await self.base.apple_music_api.get_library_album(
media_id,
)
if is_library
else await self.base.apple_music_api.get_album(
media_id,
base_media.media_metadata = (
await (
self.base.apple_music_api.get_library_album(
media_id,
)
if is_library
else self.base.apple_music_api.get_album(
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,
is_library=is_library,
)
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
]
if self.concurrency == 1:
for task in tasks:
async for result in task:
yield result
else:
for task in await safe_gather(*tasks, limit=self.concurrency):
yield task
async def _get_playlist_media(
self,
media_id: str,
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
try:
media_metadata = (
await self.base.apple_music_api.get_library_playlist(
media_id,
)
if is_library
else await self.base.apple_music_api.get_playlist(
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)
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:
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")
tasks = [
(
self._get_song_media(
media_id=track["id"],
media_metadata=track,
playlist_metadata=media_metadata,
playlist_track=index + 1,
)
if track["type"] in {"songs", "library-songs"}
else self._get_music_video_media(
media_id=track["id"],
media_metadata=track,
playlist_metadata=media_metadata,
playlist_track=index + 1,
is_library=is_library,
)
)
for index, track in enumerate(tracks)
@@ -392,25 +230,135 @@ 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_playlist_media(
self,
media_id: str,
is_library: bool = False,
) -> AsyncGenerator[AppleMusicMedia, None]:
base_media = AppleMusicMedia(media_id)
try:
base_media.media_metadata = (
await (
self.base.apple_music_api.get_library_playlist(
media_id,
)
if is_library
else self.base.apple_music_api.get_playlist(
media_id,
)
)
)["data"][0]
self._run_media_type_filter(base_media)
await self._run_flat_filter(base_media)
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,
)
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=base_media.media_metadata,
is_library=is_library,
)
if track["type"] in {"songs", "library-songs"}
else self._get_music_video_media(
media_id=track["id"],
index=index,
media_metadata=track,
playlist_metadata=base_media.media_metadata,
is_library=is_library,
)
)
for index, track in enumerate(tracks)
]
if self.concurrency == 1:
for task in tasks:
async for media in task:
yield media
else:
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 +367,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 +380,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
@@ -569,37 +431,47 @@ class AppleMusicInterface:
url_info.type,
)
if url_info.type == "song" or url_info.sub_id:
media = await self._get_song_media(
media_id=url_info.sub_id or url_info.id,
)
yield media
if (
url_info.type == "song"
or url_info.library_type == "songs"
or url_info.sub_id
):
async for media in self._get_song_media(
media_id=url_info.sub_id or url_info.id or url_info.library_id,
index=0,
total=1,
is_library=bool(url_info.library_type),
):
yield media
elif url_info.type == "music-video":
media = await self._get_music_video_media(
media_id=url_info.id,
)
yield media
elif url_info.type == "music-video" or url_info.library_type == "music-videos":
async for media in self._get_music_video_media(
media_id=url_info.id or url_info.library_id,
index=0,
total=1,
is_library=bool(url_info.library_type),
):
yield media
elif url_info.type == "album" or url_info.library_type == "albums":
async for media in self._get_album_media(
media_id=url_info.library_id or url_info.id,
media_id=url_info.id or url_info.library_id,
is_library=bool(url_info.library_type),
):
yield media
elif url_info.type == "playlist" or url_info.library_type == "playlist":
async for media in self._get_playlist_media(
media_id=url_info.library_id or url_info.id,
media_id=url_info.id or url_info.library_id,
is_library=bool(url_info.library_type),
):
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(
+136 -58
View File
@@ -1,6 +1,6 @@
import asyncio
import urllib.parse
from typing import Callable
from typing import AsyncGenerator, Callable
import m3u8
import structlog
@@ -57,13 +57,20 @@ class AppleMusicMusicVideoInterface:
return itunes_page["storePlatformData"]["product-dv"]["results"][url_media_id]
def _get_m3u8_master_url_from_webplayback(self, webplayback: dict) -> str:
log = logger.bind(action="get_m3u8_master_url_from_webplayback")
m3u8_master_url = webplayback["hls-playlist-url"]
log.debug("success", m3u8_master_url=m3u8_master_url)
return m3u8_master_url
def _get_m3u8_master_url_from_itunes_page_metadata(
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 +83,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(
@@ -85,7 +102,7 @@ class AppleMusicMusicVideoInterface:
) -> MediaTags:
log = logger.bind(
action="get_music_video_tags",
media_id=self.base.parse_catalog_media_id(metadata),
media_id=metadata["id"],
)
url_media_id = self.base.parse_media_id_from_url(metadata)
@@ -110,7 +127,8 @@ class AppleMusicMusicVideoInterface:
genre_id=int(itunes_page_metadata["genres"][0]["genreId"]),
media_type=MediaType.MUSIC_VIDEO,
storefront=self.base.itunes_api.storefront_id,
title=lookup_metadata[0]["trackCensoredName"],
title=lookup_metadata[0]["trackName"],
title_sort=lookup_metadata[0]["trackCensoredName"],
title_id=int(metadata["id"]),
rating=rating,
)
@@ -122,7 +140,8 @@ class AppleMusicMusicVideoInterface:
if not album:
return tags
tags.album = lookup_metadata[1]["collectionCensoredName"]
tags.album = lookup_metadata[1]["collectionName"]
tags.album_sort = lookup_metadata[1]["collectionCensoredName"]
tags.album_artist = lookup_metadata[1]["artistName"]
tags.album_id = int(itunes_page_metadata["collectionId"])
tags.disc = lookup_metadata[0]["discNumber"]
@@ -135,39 +154,51 @@ class AppleMusicMusicVideoInterface:
return tags
async def get_stream_info(
async def get_m3u8_master_url(
self,
metadata: dict,
itunes_page_metadata: dict,
) -> StreamInfoAv | None:
log = logger.bind(
action="get_music_video_stream_info",
media_id=self.base.parse_catalog_media_id(metadata),
)
) -> str | None:
url_media_id = self.base.parse_media_id_from_url(metadata)
m3u8_master_url = None
if url_media_id == metadata["id"]:
m3u8_master_url = self._get_m3u8_master_url_from_itunes_page_metadata(
return self._get_m3u8_master_url_from_itunes_page_metadata(
itunes_page_metadata,
)
webplayback_response = await self.base.apple_music_api.get_webplayback(
metadata["id"]
)
return self._get_m3u8_master_url_from_webplayback(
webplayback_response["songList"][0],
)
async def _get_stream_info(
self,
m3u8_master_url: str | None,
codec: MusicVideoCodec,
) -> StreamInfoAv | None:
log = logger.bind(
action="get_music_video_stream_info",
m3u8_master_url=m3u8_master_url,
codec=codec.value,
)
if not m3u8_master_url:
webplayback_response = await self.base.apple_music_api.get_webplayback(
metadata["id"]
)
m3u8_master_url = self._get_m3u8_master_url_from_webplayback(
webplayback_response["songList"][0],
)
log.debug("no_m3u8_master_url")
return None
playlist_master_m3u8_obj = m3u8.loads(
(await self.base.get_response(m3u8_master_url)).text
)
playlist_master_m3u8_obj.base_uri = m3u8_master_url.rpartition("/")[0]
stream_info_video = await self._get_stream_info_video(playlist_master_m3u8_obj)
stream_info_video = await self._get_stream_info_video(
playlist_master_m3u8_obj,
codec,
)
stream_info_audio = await self._get_stream_info_audio(
playlist_master_m3u8_obj.data,
codec,
)
if not stream_info_video or not stream_info_audio:
return None
@@ -195,20 +226,20 @@ class AppleMusicMusicVideoInterface:
def _get_video_playlist_from_resolution(
self,
video_playlists: list[m3u8.Playlist],
codec: MusicVideoCodec,
) -> m3u8.Playlist | None:
playlist_results = []
for codec_index, codec in enumerate(self.codec_priority):
for playlist in video_playlists:
if playlist.stream_info.codecs.startswith(codec.fourcc()):
playlist_results.append((codec_index, playlist))
playlist_results = [
playlist
for playlist in video_playlists
if playlist.stream_info.codecs.startswith(codec.fourcc)
]
if not playlist_results:
return None
def sort_key(
item: tuple[int, m3u8.Playlist],
) -> tuple[bool, int, int, int, int]:
codec_index, playlist = item
playlist: m3u8.Playlist,
) -> tuple[bool, int, int, int]:
playlist_resolution = playlist.stream_info.resolution[-1]
bandwidth = playlist.stream_info.bandwidth
exceeds_resolution = playlist_resolution > int(self.resolution)
@@ -217,13 +248,12 @@ class AppleMusicMusicVideoInterface:
return (
exceeds_resolution,
resolution_difference,
codec_index,
-playlist_resolution,
-bandwidth,
)
playlist_results.sort(key=sort_key)
return playlist_results[0][1]
return playlist_results[0]
def _get_best_stereo_audio_playlist(
self,
@@ -302,12 +332,14 @@ class AppleMusicMusicVideoInterface:
async def _get_stream_info_video(
self,
playlist_master_m3u8_obj: m3u8.M3U8,
codec: MusicVideoCodec,
) -> StreamInfo | None:
stream_info = StreamInfo()
if MusicVideoCodec.ASK not in self.codec_priority:
if codec != MusicVideoCodec.ASK:
playlist = self._get_video_playlist_from_resolution(
playlist_master_m3u8_obj.playlists,
codec,
)
else:
playlist = await self._get_video_playlist_from_user(
@@ -333,10 +365,11 @@ class AppleMusicMusicVideoInterface:
async def _get_stream_info_audio(
self,
playlist_master_data: dict,
codec: MusicVideoCodec,
) -> StreamInfo | None:
stream_info = StreamInfo()
if MusicVideoCodec.ASK not in self.codec_priority:
if codec != MusicVideoCodec.ASK:
playlist = self._get_best_stereo_audio_playlist(playlist_master_data)
else:
playlist = await self._get_audio_playlist_from_user(playlist_master_data)
@@ -356,6 +389,27 @@ class AppleMusicMusicVideoInterface:
return stream_info
async def get_stream_info(
self,
media_id: str,
m3u8_master_url: str | None,
) -> StreamInfoAv:
stream_info = None
for codec in self.codec_priority:
stream_info = await self._get_stream_info(m3u8_master_url, codec)
if stream_info:
break
if not stream_info:
raise GamdlInterfaceFormatNotAvailableError(
media_id=media_id,
codec=[codec.value for codec in self.codec_priority],
)
return stream_info
async def get_decryption_key(
self,
stream_info: StreamInfoAv,
@@ -378,42 +432,64 @@ 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_library_music_video(media.media_id)
if media.is_library
else self.base.apple_music_api.get_music_video(media.media_id)
)
)["data"][0]
if not self.base.is_media_streamable(music_video_metadata):
if media.media_metadata["attributes"].get("playParams", {}).get("isLibrary"):
catalog_metadata = self.base.get_catalog_metadata_from_library(
media.media_metadata
)
if catalog_metadata:
media.media_id = catalog_metadata["id"]
media.is_library = False
media.media_metadata = catalog_metadata
if media.is_library:
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
if playlist_metadata and playlist_track:
media.playlist_metadata = playlist_metadata
yield media
if not self.base.is_media_streamable(media.media_metadata):
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
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 = 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:
playback = None
media.tags = await self.get_tags(
media.media_metadata,
itunes_page_metadata,
)
m3u8_master_url = await self.get_m3u8_master_url(
media.media_metadata,
itunes_page_metadata,
)
media.stream_info = await self.get_stream_info(
music_video_metadata,
itunes_page_metadata,
media.media_id,
m3u8_master_url,
)
if not media.stream_info:
raise GamdlInterfaceFormatNotAvailableError(
media.media_id,
self.codec_priority,
)
if (
not media.stream_info.video_track.widevine_pssh
@@ -423,4 +499,6 @@ class AppleMusicMusicVideoInterface:
media.decryption_key = await self.get_decryption_key(media.stream_info)
return media
media.partial = False
yield media
+619 -521
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -122,7 +122,9 @@ class StreamInfo:
codec: str = None
width: int = None
height: int = None
legacy: bool = None
drm_free: bool = False
use_cenc: bool = False
use_single_content_key: bool = True
@dataclass
@@ -155,7 +157,11 @@ class Cover:
@dataclass
class AppleMusicMedia:
media_id: str
media_metadata: dict
is_library: bool = False
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 +171,6 @@ class AppleMusicMedia:
tags: MediaTags | None = None
stream_info: StreamInfoAv | None = None
decryption_key: DecryptionKeyAv | None = None
flat_filter_result: Any = None
@dataclass
+19 -11
View File
@@ -1,5 +1,6 @@
import asyncio
from collections.abc import Callable
from typing import AsyncGenerator
import structlog
@@ -78,6 +79,7 @@ class AppleMusicUploadedVideoInterface:
file_format=MediaFileFormat.M4V,
video_track=StreamInfo(
stream_url=stream_url,
drm_free=True,
),
)
@@ -105,22 +107,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 = media["id"]
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.7.4"
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.7.4"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },