Compare commits

...

134 Commits

Author SHA1 Message Date
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
Rafael Moraes d7a83bab50 Use playlist_tags artist/title/track fields 2026-04-21 11:55:48 -03:00
Rafael Moraes 4aa70733d6 Handle URL parse errors and optional tracebacks 2026-04-21 11:50:55 -03:00
Rafael Moraes 7063900dd4 Check for stream_info before setting staged_path 2026-04-21 11:48:44 -03:00
Rafael Moraes ff5298c0ae Omit message in synced lyrics error 2026-04-21 11:44:17 -03:00
Rafael Moraes 3c54368f03 Refactor media parsing into helper 2026-04-21 11:43:13 -03:00
Rafael Moraes 905bbfd5ca Pass synced_lyrics_only to skip_stream_info 2026-04-21 11:33:17 -03:00
Rafael Moraes d84bc2c695 Add skip_stream_info option to SongInterface 2026-04-21 11:32:50 -03:00
Rafael Moraes 82ab9827eb Clarify yt-dlp usage in README 2026-04-21 11:26:42 -03:00
Rafael Moraes ff5dc4f20c Mention mp4decrypt in Music Videos entry 2026-04-21 10:51:11 -03:00
Rafael Moraes a99707666b Refactor README 2026-04-21 10:49:51 -03:00
Rafael Moraes 91db55adc3 Require mp4decrypt for music videos 2026-04-21 10:49:44 -03:00
Rafael Moraes ae8d4a27aa Remove ffmpeg decryption_key support in music_video 2026-04-21 10:48:41 -03:00
Rafael Moraes cfc4673082 Add SQLite database registry for downloaded media 2026-04-21 10:44:33 -03:00
Rafael Moraes 64a20f030a Fail on flat-filter excluded media
Introduce GamdlDownloaderFlatFilterExcludedError and raise it during AppleMusicDownloader processing when item.media.flat_filter_result is truthy. This aborts further processing/download for media excluded by the flat filter and includes the media id in the error message. Also import the new exception in the downloader module.
2026-04-21 10:36:08 -03:00
Rafael Moraes c4536963f8 Update README usage example for new API 2026-04-21 10:21:51 -03:00
Rafael Moraes 0b318156a4 Bump package version to 3.0 2026-04-21 10:19:09 -03:00
Rafael Moraes 30b3f36905 Refactor CLI module 2026-04-21 10:15:49 -03:00
Rafael Moraes 9b76ab90a7 Refine codec callback type hints 2026-04-21 10:14:33 -03:00
Rafael Moraes f3dfd3d9d8 Pass full playlist dict to ask_codec_function 2026-04-21 10:11:24 -03:00
Rafael Moraes 95c6e6dce7 Pass media metadata to artist selector 2026-04-21 10:03:58 -03:00
Rafael Moraes 2fd7ad9334 Support async and optional callbacks in interfaces 2026-04-21 09:00:41 -03:00
Rafael Moraes 97e8fd2223 Log cleanup success only when performed 2026-04-21 08:32:43 -03:00
Rafael Moraes 119a39c4fe Refactor imports in downloader.py 2026-04-20 11:57:32 -03:00
Rafael Moraes f9d62ee84b Refactor downloader module 2026-04-20 11:56:32 -03:00
Rafael Moraes 939e9459ef Replace _base with base in interfaces 2026-04-20 10:26:39 -03:00
Rafael Moraes de76ce898e Use _base.apple_music_api for AppleMusic calls 2026-04-20 10:23:27 -03:00
Rafael Moraes 5bbe87500a Use composition for AppleMusic interfaces 2026-04-20 10:22:56 -03:00
Rafael Moraes 61ea24bfdd Remove extra tags fetching and preview parsing 2026-04-20 09:55:57 -03:00
Rafael Moraes b5837bdca5 Fix ALAC duration and timescale handling 2026-04-20 09:53:38 -03:00
Rafael Moraes b21a9cc35b Add httpx-retries, structlog & dev deps 2026-04-20 09:49:19 -03:00
Rafael Moraes fe6fe54880 Merge pull request #289 from SiddharthManthan/media-length
fix (alac): resolution for incorrect duration tags in ALAC downloads
2026-04-20 09:33:15 -03:00
Rafael Moraes 56748797eb Re-export exceptions in api package 2026-04-19 19:08:42 -03:00
Rafael Moraes 9d504a34b0 Add exports for gamdl.interface package 2026-04-19 19:08:18 -03:00
Rafael Moraes b59d7b9a73 Refactor interface module 2026-04-19 17:09:52 -03:00
Rafael Moraes d3b13ebe26 Standardize log.debug messages to 'success' 2026-04-19 16:25:38 -03:00
Rafael Moraes c2bfe4f2f3 Standardize debug messages to 'success' 2026-04-19 16:21:30 -03:00
Rafael Moraes 178dc8822e Store storefront and language in ItunesApi 2026-04-19 16:14:33 -03:00
Rafael Moraes 2a966f178f Remove HTTP helpers and sequential_gather 2026-04-19 15:41:02 -03:00
Rafael Moraes 4cb771a925 Add retry transport to Apple Music HTTP client 2026-04-19 14:04:47 -03:00
Rafael Moraes 102dce2b75 Remove redundant debug log in apple_music.py 2026-04-14 07:49:00 -03:00
Rafael Moraes 27630b5657 Update API imports to new module names 2026-04-13 22:26:06 -03:00
Rafael Moraes 8335af0f79 Refactor API exception classes 2026-04-13 22:25:48 -03:00
Rafael Moraes e3ce405a41 Refactor Apple Music constants and add API URIs 2026-04-13 22:25:31 -03:00
Rafael Moraes c5e001fda5 Refactor iTunes API client 2026-04-13 22:25:09 -03:00
Rafael Moraes eba97c8344 Refactor Apple Music API client 2026-04-13 22:24:58 -03:00
Siddharth Manthan 0413d133b5 fix (alac): resolution for incorrect duration tags in ALAC downloads
- Updated amdecrypt.py to correctly patch both timescale and duration in mdhd boxes (support for v0 and v1)
- Added tag filtering in downloader_base.py and interface_song.py to prevent preview-related tags (e.g., ©dur, iTunSMPB) from overwriting full-track metadata
2026-04-10 22:45:11 +05:30
Rafael Moraes e330e11d82 Bump version to 2.9.3 2026-03-08 13:37:46 -03:00
Rafael Moraes bebfcb02d8 Use trex defaults for sample duration/size 2026-03-08 13:35:21 -03:00
Rafael Moraes 29f68f6bc4 Bump version to 2.9.2 2026-03-05 15:08:42 -03:00
Rafael Moraes e77c6b24b4 Merge pull request #277 from LiuqingDu/fix-all-albums
Fix KeyError during artist download pagination
2026-03-05 15:07:16 -03:00
Liuqing Du ba315dcb95 Fix KeyError during artist download pagination 2026-02-28 11:50:52 -06:00
Rafael Moraes 4187fad734 Bump version to 2.9.1 2026-02-25 19:13:13 -03:00
Rafael Moraes f36edf4bbd Add 'Apple Music Classical' to README 2026-02-25 19:12:29 -03:00
Rafael Moraes 50478d427e Add Artist Auto-Select options to README 2026-02-25 19:11:20 -03:00
Rafael Moraes 45461007a9 Add artist auto select flag; rename song codec flag 2026-02-25 19:07:33 -03:00
Rafael Moraes 79a03d4f4c Rename artist_selection to artist_auto_select in CLI 2026-02-25 19:05:07 -03:00
Rafael Moraes beb508529a Rename ArtistDownloadSelection to ArtistAutoSelect 2026-02-25 19:04:52 -03:00
Rafael Moraes 87cf8c7789 Add artist_selection CLI option 2026-02-25 19:01:57 -03:00
Rafael Moraes 9e3f740eec Add ArtistDownloadSelection and auto-select option 2026-02-25 19:01:37 -03:00
Rafael Moraes 7281f5c949 Support song codec priority list 2026-02-25 18:16:34 -03:00
Rafael Moraes d32781b23f Skip wrapper decryption for legacy codecs 2026-02-25 17:52:15 -03:00
Rafael Moraes 5f2c74399e Merge pull request #276 from symphoniacus/fix-classical-url-parsing
fix: add support for Apple Music Classical URLs
2026-02-25 17:48:15 -03:00
Rafael Moraes 6b67c435fa Fix spacing in CLI warning message 2026-02-25 15:12:46 -03:00
Rafael Moraes 240ba7d4de Handle 404 ApiError for Apple Music calls 2026-02-25 15:09:52 -03:00
Rafael Moraes 02c19963b4 Clarify wrapper requirements in README 2026-02-25 14:55:33 -03:00
Rafael Moraes 2e2fef1426 Bump version to 2.9 2026-02-25 14:54:28 -03:00
Rafael Moraes ae3b2e1c6d Skip fetching covers when CoverFormat.RAW 2026-02-25 14:48:47 -03:00
Rafael Moraes 6516855be9 Fix Apple Music cover URL and async image read 2026-02-25 14:48:35 -03:00
Rafael Moraes 77cbb8a7ca Clarify README prerequisites and config table 2026-02-25 14:33:50 -03:00
Rafael Moraes 18bc6595a9 Add music_video_remux_mode and adjust checks 2026-02-25 14:33:32 -03:00
Rafael Moraes da2c3d5f1e Move remux_mode to music video downloader 2026-02-25 14:33:08 -03:00
Rafael Moraes abe364aad1 Remove unused imports in downloader_song.py 2026-02-25 14:32:29 -03:00
Rafael Moraes 10b529d6fd Remove hardcoded song decryption key 2026-02-25 14:08:57 -03:00
Rafael Moraes afe42848d0 Refactor song decryption and staging 2026-02-25 14:08:35 -03:00
Rafael Moraes b3b5e6d1b2 Add sample encryption parsing and hex-key decryption 2026-02-25 14:08:09 -03:00
Rafael Moraes 9f86c7436d Bump version to 2.8.7 2026-02-25 12:36:31 -03:00
Rafael Moraes 74a26d0342 Preserve original moov boxes and metadata 2026-02-25 12:30:29 -03:00
Rafael Moraes 37895dea1c Add AI-generated notice to amdecrypt.py 2026-02-25 00:13:58 -03:00
Rafael Moraes 04396a7f3f Bump version to 2.8.6 2026-02-25 00:09:53 -03:00
Rafael Moraes bde49305c9 Select audio track for moof/mdat extraction 2026-02-25 00:08:36 -03:00
Rafael Moraes b0c3b4630d Make decrypt_samples async and use asyncio streams 2026-02-24 23:09:32 -03:00
Rafael Moraes fd30ab861b Update help text for --use-wrapper 2026-02-23 23:56:06 -03:00
Rafael Moraes b1827e8d1b Bump version to 2.8.5 2026-02-23 23:50:47 -03:00
Rafael Moraes fe020442b1 Fetch song details when extendedAssetUrls missing 2026-02-23 23:50:20 -03:00
Rafael Moraes 87b8492b4f Include legacy codec in wrapper bypass check 2026-02-23 23:46:54 -03:00
Rafael Moraes f961ade8d8 Remove forced AAC override for wrapper usage 2026-02-23 23:46:40 -03:00
Rafael Moraes 471a2e85ac Include offset from next_uri in AMP requests 2026-02-23 23:43:47 -03:00
Rafael Moraes a17b1296d8 Fix spacing in wrapper codec warning 2026-02-23 23:31:33 -03:00
Rafael Moraes 22628c4c53 Bypass wrapper for music videos 2026-02-23 23:30:46 -03:00
Rafael Moraes 23a5be37b1 Handle wrapper: skip exec checks and adjust codec 2026-02-23 23:18:28 -03:00
Rafael Moraes 9aa7a2e199 Use media_type_key for music-videos check 2026-02-23 23:09:19 -03:00
Rafael Moraes 31d07172a6 Include live albums in artist views 2026-02-23 23:07:09 -03:00
Rafael Moraes fbe0167f0e Add live albums support 2026-02-23 23:06:56 -03:00
Rafael Moraes 1d621568a0 README: simplify wrapper docs and config 2026-02-23 22:59:51 -03:00
Rafael Moraes fa31649d76 Preserve moov box timestamps in decrypted m4a 2026-02-23 22:57:03 -03:00
Rafael Moraes 16d8dc925a Handle wrapper connect errors; remove amdecrypt 2026-02-23 22:00:38 -03:00
Rafael Moraes 46d1ec11dc Add Python amdecrypt and remove amdecrypt dep 2026-02-23 22:00:22 -03:00
Rafael Moraes f68e76ce8b Add ApiError and centralize AMP requests 2026-02-23 21:52:53 -03:00
Rafael Moraes 42df1f7f5e Make safe_json return None on parse error 2026-02-23 21:44:47 -03:00
symphoniacus d11e937c6a fix: allow Apple Music Classical URLs (classical.music.apple.com) 2026-02-14 19:24:56 +01:00
Rafael Moraes a7c8ff4297 Fix relative import for GamdlError in exceptions.py 2026-01-30 12:22:30 -03:00
Rafael Moraes 5332e0e1c0 Move GamdlError to utils and update imports 2026-01-30 12:21:39 -03:00
Rafael Moraes b8ea1d0039 Add support for downloading artist top songs 2026-01-24 10:54:55 -03:00
Rafael Moraes 4de0e3d1f8 Add 'views' parameter to artist API request 2026-01-24 10:54:49 -03:00
Rafael Moraes c770ff361f Refactor config file loading with decorator
Introduced ConfigFile.loader decorator to handle config file loading in CLI entrypoint. Removed manual config file loading logic from main function for improved modularity and readability.
2026-01-17 01:37:49 -03:00
Rafael Moraes d6afb680be Exclude help and version from CLI config parsing 2026-01-16 23:12:48 -03:00
Rafael Moraes b15f404849 Refactor config file loading in CLI 2026-01-16 23:06:48 -03:00
Rafael Moraes 072d71caaf Remove explicit click param types from CLI config 2026-01-16 22:53:14 -03:00
Rafael Moraes 7e132c27de Refactor config parameter handling to use Click params 2026-01-16 22:49:45 -03:00
45 changed files with 6284 additions and 3545 deletions
+158 -117
View File
@@ -26,31 +26,44 @@ A command-line app for downloading Apple Music songs, music videos and post vide
- **Apple Music Cookies** - Export your browser cookies in Netscape format while logged in with an active subscription at the Apple Music website:
- **Firefox**: [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt)
- **Chromium**: [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
- **FFmpeg** - Must be in your system PATH
- **Windows**: [AnimMouse's FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases)
- **Linux**: [John Van Sickle's FFmpeg Builds](https://johnvansickle.com/ffmpeg/)
### Optional
### Dependencies
Add these tools to your system PATH for additional features:
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:
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required for `mp4box` remux mode, music videos, and experimental codecs
- **[MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)** - Required for `mp4box` remux mode
- **[N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)** - Required for `nm3u8dlre` download mode, which is faster than the default downloader
- **[Wrapper & amdecrypt](#-wrapper--amdecrypt)** - For downloading songs in ALAC and other experimental codecs without API limitations
| 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 |
#### Tool Reference
| 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 |
## 📦 Installation
**Install Gamdl via pip:**
1. **Install Gamdl via pip:**
```bash
pip install gamdl
```
```bash
pip install gamdl
```
**Setup cookies:**
2. **Set up the cookies file:**
- Place the cookies file in the working directory as `cookies.txt`, or
- Specify the path using `--cookies-path` or in the config file
1. Place your cookies file in the working directory as `cookies.txt`, or
2. 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.
## 🚀 Usage
@@ -66,6 +79,7 @@ gamdl [OPTIONS] URLS...
- Music Videos
- Artists
- Post Videos
- Apple Music Classical
### Examples
@@ -109,62 +123,66 @@ The file is created automatically on first run. Command-line arguments override
### Configuration Options
| Option | Description | Default |
| ------------------------------- | ------------------------------- | ---------------------------------------------- |
| **General Options** | | |
| `--read-urls-as-txt`, `-r` | Read URLs from text files | `false` |
| `--config-path` | Config file path | `<home>/.gamdl/config.ini` |
| `--log-level` | Logging level | `INFO` |
| `--log-file` | Log file path | - |
| `--no-exceptions` | Don't print exceptions | `false` |
| `--no-config-file`, `-n` | Don't use a config file | `false` |
| **Apple Music Options** | | |
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
| `--wrapper-account-url` | Wrapper account URL | `http://127.0.0.1:30020` |
| `--language`, `-l` | Metadata language | `en-US` |
| **Output Options** | | |
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
| `--temp-path` | Temporary directory path | `.` |
| `--wvd-path` | .wvd file path | - |
| `--overwrite` | Overwrite existing files | `false` |
| `--save-cover`, `-s` | Save cover as separate file | `false` |
| `--save-playlist` | Save M3U8 playlist file | `false` |
| **Download Options** | | |
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
| `--mp4decrypt-path` | mp4decrypt executable path | `mp4decrypt` |
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
| `--mp4box-path` | MP4Box executable path | `MP4Box` |
| `--amdecrypt-path` | amdecrypt executable path | `amdecrypt` |
| `--use-wrapper` | Use wrapper and amdecrypt | `false` |
| `--wrapper-decrypt-ip` | Wrapper decryption server IP | `127.0.0.1:10020` |
| `--download-mode` | Download mode | `ytdlp` |
| `--remux-mode` | Remux mode | `ffmpeg` |
| `--cover-format` | Cover format | `jpg` |
| **Template Options** | | |
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
| `--single-disc-file-template` | Single disc file template | `{track:02d} {title}` |
| `--multi-disc-file-template` | Multi disc file template | `{disc}-{track:02d} {title}` |
| `--no-album-file-template` | No album file template | `{title}` |
| `--playlist-file-template` | Playlist file template | `Playlists/{playlist_artist}/{playlist_title}` |
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
| `--exclude-tags` | Comma-separated tags to exclude | - |
| `--cover-size` | Cover size in pixels | `1200` |
| `--truncate` | Max filename length | - |
| **Song Options** | | |
| `--song-codec` | Song codec | `aac-legacy` |
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
| `--use-album-date` | Use album release date for songs | `false` |
| `--fetch-extra-tags` | Fetch extra tags from preview (normalization and smooth playback) | `false` |
| **Music Video Options** | | |
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
| `--music-video-remux-format` | Music video remux format | `m4v` |
| `--music-video-resolution` | Max music video resolution | `1080p` |
| **Post Video Options** | | |
| `--uploaded-video-quality` | Post video quality | `best` |
| 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 | - |
| `--wrapper-m3u8-ip` | Wrapper m3u8 IP address and port | - |
| **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` |
### Template Variables
@@ -194,6 +212,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`
@@ -255,15 +276,19 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
- `best` - Up to 1080p with AAC 256kbps
- `ask` - Interactive quality selection
## ⚙️ Wrapper & amdecrypt
### Artist Auto-Select Options
Use the [wrapper](https://github.com/WorldObservationLog/wrapper) and [amdecrypt](https://github.com/glomatico/amdecrypt) to download songs in ALAC and other experimental codecs without API limitations. Cookies are not required when using the wrapper.
- `main-albums`
- `compilation-albums`
- `live-albums`
- `singles-eps`
- `all-albums`
- `top-songs`
- `music-videos`
### Prerequisites
## ⚙️ Wrapper
- **[wrapper](https://github.com/WorldObservationLog/wrapper)** - Refer to the repository for installation
- **[amdecrypt](https://github.com/glomatico/amdecrypt)** - Refer to the repository for installation
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required by amdecrypt to decrypt protected files
Use the [wrapper](https://github.com/WorldObservationLog/wrapper) to download songs in ALAC and other experimental codecs without API limitations. Cookies are not required when using the wrapper.
### Setup Instructions
@@ -278,7 +303,7 @@ Use Gamdl as a library in your Python projects:
```python
import asyncio
from gamdl.api import AppleMusicApi, ItunesApi
from gamdl.api import AppleMusicApi
from gamdl.downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
@@ -287,63 +312,79 @@ from gamdl.downloader import (
AppleMusicUploadedVideoDownloader,
)
from gamdl.interface import (
AppleMusicBaseInterface,
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
)
async def main():
# Create AppleMusicApi instance (from cookies or wrapper)
# Create AppleMusicApi instance from cookies
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
cookies_path="cookies.txt",
)
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
)
# Check subscription
assert apple_music_api.active_subscription
if not apple_music_api.active_subscription:
print("No active Apple Music subscription")
return
# Set up interfaces
interface = AppleMusicInterface(apple_music_api, itunes_api)
song_interface = AppleMusicSongInterface(interface)
music_video_interface = AppleMusicMusicVideoInterface(interface)
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
# Set up base downloader and specialized downloaders
base_downloader = AppleMusicBaseDownloader()
song_downloader = AppleMusicSongDownloader(
base_downloader=base_downloader,
interface=song_interface,
)
music_video_downloader = AppleMusicMusicVideoDownloader(
base_downloader=base_downloader,
interface=music_video_interface,
)
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
base_downloader=base_downloader,
interface=uploaded_video_interface,
# Create base interface
base_interface = await AppleMusicBaseInterface.create(
apple_music_api=apple_music_api,
)
# Main downloader
downloader = AppleMusicDownloader(
# Create specialized interfaces
song_interface = AppleMusicSongInterface(
base=base_interface,
)
music_video_interface = AppleMusicMusicVideoInterface(
base=base_interface,
)
uploaded_video_interface = AppleMusicUploadedVideoInterface(
base=base_interface,
)
# Create main interface
interface = AppleMusicInterface(
song=song_interface,
music_video=music_video_interface,
uploaded_video=uploaded_video_interface,
)
# Create base downloader
base_downloader = AppleMusicBaseDownloader(
interface=interface,
base_downloader=base_downloader,
song_downloader=song_downloader,
music_video_downloader=music_video_downloader,
uploaded_video_downloader=uploaded_video_downloader,
)
# Download a song
# Create specialized downloaders
song_downloader = AppleMusicSongDownloader(base=base_downloader)
music_video_downloader = AppleMusicMusicVideoDownloader(
base=base_downloader,
)
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(base=base_downloader)
# Create main downloader
downloader = AppleMusicDownloader(
song=song_downloader,
music_video=music_video_downloader,
uploaded_video=uploaded_video_downloader,
)
# Download from URL
url = "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
url_info = downloader.get_url_info(url)
if url_info:
download_queue = await downloader.get_download_queue(url_info)
if download_queue:
for download_item in download_queue:
await downloader.download(download_item)
download_queue = []
async for media in downloader.get_download_item_from_url(url):
download_queue.append(media)
for download_item in download_queue:
try:
await downloader.download(download_item)
except Exception as e:
print(f"Error downloading: {e}")
if __name__ == "__main__":
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.8.4"
__version__ = "3.2"
+3 -2
View File
@@ -1,2 +1,3 @@
from .apple_music_api import AppleMusicApi
from .itunes_api import ItunesApi
from .apple_music import AppleMusicApi
from .exceptions import *
from .itunes import ItunesApi
+610
View File
@@ -0,0 +1,610 @@
import re
from http.cookiejar import MozillaCookieJar
from urllib.parse import parse_qs, urlparse
import httpx
import structlog
from httpx_retries import Retry, RetryTransport
from .constants import (
APPLE_MUSIC_ACCOUNT_INFO_API_URI,
APPLE_MUSIC_ALBUM_API_URI,
APPLE_MUSIC_AMP_API_URL,
APPLE_MUSIC_ARTIST_API_URI,
APPLE_MUSIC_COOKIE_DOMAIN,
APPLE_MUSIC_HOMEPAGE_URL,
APPLE_MUSIC_LIBRARY_ALBUM_API_URI,
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI,
APPLE_MUSIC_LICENSE_API_URL,
APPLE_MUSIC_MUSIC_VIDEO_API_URI,
APPLE_MUSIC_PLAYLIST_API_URI,
APPLE_MUSIC_SEARCH_API_URI,
APPLE_MUSIC_SONG_API_URI,
APPLE_MUSIC_UPLOADED_VIDEO_API_URL,
APPLE_MUSIC_WEBPLAYBACK_API_URL,
)
from .exceptions import GamdlApiResponseError
logger = structlog.get_logger(__name__)
class AppleMusicApi:
def __init__(
self,
client: httpx.AsyncClient,
token: str,
storefront: str,
language: str,
media_user_token: str | None = None,
account_info: dict | None = None,
) -> None:
self.token = token
self.storefront = storefront
self.language = language
self.media_user_token = media_user_token
self.account_info = account_info
self.client = client
@property
def active_subscription(self) -> bool:
if not self.account_info:
return False
return (
self.account_info.get("meta", {})
.get("subscription", {})
.get("active", False)
)
@property
def account_restrictions(self) -> dict | None:
if not self.account_info:
return None
data = self.account_info.get("data", [])
if not data:
return None
return data[0].get("attributes", {}).get("restrictions")
@staticmethod
async def get_token() -> str:
log = logger.bind(action="get_token")
response = None
async with httpx.AsyncClient() as client:
try:
response = await client.get(
APPLE_MUSIC_HOMEPAGE_URL,
follow_redirects=True,
)
response.raise_for_status()
home_page = response.text
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching Apple Music homepage",
status_code=response.status_code if response is not None else None,
)
index_js_uri_match = re.search(
r"/(assets/index-legacy[~-][^/\"]+\.js)",
home_page,
)
if not index_js_uri_match:
raise GamdlApiResponseError(
"Error finding index.js URI in Apple Music homepage"
)
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(
f"{APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}"
)
response.raise_for_status()
index_js_page = response.text
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching index.js page",
status_code=response.status_code if response is not None else None,
)
token_match = re.search('(?=eyJh)(.*?)(?=")', index_js_page)
if not token_match:
raise GamdlApiResponseError("Error finding token in index.js page")
token = token_match.group(1)
log.debug("success")
return token
@staticmethod
async def get_account_info(
token: str,
media_user_token: str,
meta: str = "subscription",
) -> dict:
log = logger.bind(action="get_account_info", meta=meta)
response = None
async with httpx.AsyncClient() as client:
try:
response = await client.get(
APPLE_MUSIC_AMP_API_URL + APPLE_MUSIC_ACCOUNT_INFO_API_URI,
params={
"meta": meta,
},
headers={
"authorization": f"Bearer {token}",
"origin": APPLE_MUSIC_HOMEPAGE_URL,
"cookie": f"media-user-token={media_user_token}",
},
)
response.raise_for_status()
account_info = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching account info",
status_code=response.status_code if response is not None else None,
)
log.debug("success", account_info=account_info)
return account_info
@classmethod
async def create(
cls,
storefront: str | None = "us",
language: str = "en-US",
token: str | None = None,
media_user_token: str | None = None,
) -> "AppleMusicApi":
token = token or await cls.get_token()
account_info = (
await cls.get_account_info(token, media_user_token)
if media_user_token
else None
)
storefront = (
account_info["meta"]["subscription"]["storefront"]
if account_info
else storefront
)
if not storefront:
raise ValueError(
"Storefront must be provided if it cannot be determined from account info"
)
client = httpx.AsyncClient(
headers={
"authorization": f"Bearer {token}",
"origin": APPLE_MUSIC_HOMEPAGE_URL,
},
transport=RetryTransport(
retry=Retry(
total=6,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
)
),
)
if media_user_token:
client.headers.update(
{
"cookie": f"media-user-token={media_user_token}",
}
)
api = cls(
client=client,
token=token,
storefront=storefront,
language=language,
media_user_token=media_user_token,
account_info=account_info,
)
return api
@classmethod
async def create_from_netscape_cookies(
cls,
cookies_path: str = "./cookies.txt",
*args,
**kwargs,
) -> "AppleMusicApi":
cookies = MozillaCookieJar(cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
parse_cookie = lambda name: next(
(
cookie.value
for cookie in cookies
if cookie.name == name and cookie.domain == APPLE_MUSIC_COOKIE_DOMAIN
),
None,
)
media_user_token = parse_cookie("media-user-token")
if not media_user_token:
raise ValueError(
'"media-user-token" cookie not found in cookies. '
"Make sure you have exported the cookies from the Apple Music webpage "
"and are logged in with an active subscription."
)
return await cls.create(
media_user_token=media_user_token,
*args,
**kwargs,
)
@classmethod
async def create_from_wrapper(
cls,
wrapper_account_url: str = "http://127.0.0.1:30020/",
*args,
**kwargs,
) -> "AppleMusicApi":
response = None
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 if response is not None else None,
)
return await cls.create(
media_user_token=wrapper_account_info["music_token"],
token=wrapper_account_info["dev_token"],
*args,
**kwargs,
)
async def _amp_request(
self,
uri: str,
params: dict | None = None,
) -> dict:
response = None
try:
response = await self.client.get(
APPLE_MUSIC_AMP_API_URL + uri,
params=params,
)
response.raise_for_status()
response_json = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching from AMP API",
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:
raise GamdlApiResponseError(
"Error fetching from AMP API",
content=response_json["errors"],
)
return response_json
async def get_song(
self,
song_id: str,
extend: str = "extendedAssetUrls",
include: str = "lyrics,albums",
) -> dict:
log = logger.bind(action="get_song", song_id=song_id)
song = await self._amp_request(
APPLE_MUSIC_SONG_API_URI.format(
storefront=self.storefront,
song_id=song_id,
),
{
"extend": extend,
"include": include,
},
)
log.debug("success", song=song)
return song
async def get_music_video(
self,
music_video_id: str,
include: str = "albums",
) -> dict:
log = logger.bind(action="get_music_video", music_video_id=music_video_id)
music_video = await self._amp_request(
APPLE_MUSIC_MUSIC_VIDEO_API_URI.format(
storefront=self.storefront,
music_video_id=music_video_id,
),
{
"include": include,
},
)
log.debug("success", music_video=music_video)
return music_video
async def get_uploaded_video(
self,
uploaded_video_id: str,
) -> dict:
log = logger.bind(
action="get_uploaded_video", uploaded_video_id=uploaded_video_id
)
uploaded_video = await self._amp_request(
APPLE_MUSIC_UPLOADED_VIDEO_API_URL.format(
storefront=self.storefront,
uploaded_video_id=uploaded_video_id,
)
)
log.debug("success", uploaded_video=uploaded_video)
return uploaded_video
async def get_album(
self,
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_album", album_id=album_id)
album = await self._amp_request(
APPLE_MUSIC_ALBUM_API_URI.format(
storefront=self.storefront,
album_id=album_id,
),
{
"extend": extend,
},
)
log.debug("success", album=album)
return album
async def get_playlist(
self,
playlist_id: str,
limit_tracks: int = 300,
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_playlist", playlist_id=playlist_id)
playlist = await self._amp_request(
APPLE_MUSIC_PLAYLIST_API_URI.format(
storefront=self.storefront,
playlist_id=playlist_id,
),
{
"limit[tracks]": limit_tracks,
"extend": extend,
},
)
log.debug("success", playlist=playlist)
return playlist
async def get_artist(
self,
artist_id: str,
include: str = "albums,music-videos",
views: str = "full-albums,compilation-albums,live-albums,singles,top-songs",
limit: int = 100,
) -> dict:
log = logger.bind(action="get_artist", artist_id=artist_id)
artist = await self._amp_request(
APPLE_MUSIC_ARTIST_API_URI.format(
storefront=self.storefront,
artist_id=artist_id,
),
{
"include": include,
"views": views,
**{
f"limit[{_include}]": limit
for _include in [*include.split(","), *views.split(",")]
},
},
)
log.debug("success", artist=artist)
return artist
async def get_library_album(
self,
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_album", album_id=album_id)
album = await self._amp_request(
APPLE_MUSIC_LIBRARY_ALBUM_API_URI.format(
album_id=album_id,
),
{
"extend": extend,
},
)
log.debug("success", album=album)
return album
async def get_library_playlist(
self,
playlist_id: str,
include: str = "tracks",
limit: int = 100,
extend: str = "extendedAssetUrls",
) -> dict:
log = logger.bind(action="get_library_playlist", playlist_id=playlist_id)
playlist = await self._amp_request(
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI.format(
playlist_id=playlist_id,
),
{
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
"extend": extend,
},
)
log.debug("success", playlist=playlist)
return playlist
async def get_search_results(
self,
term: str,
types: str = "songs,music-videos,albums,playlists,artists",
limit: int = 50,
offset: int = 0,
) -> dict:
log = logger.bind(action="get_search_results", term=term, types=types)
search_results = await self._amp_request(
APPLE_MUSIC_SEARCH_API_URI.format(
storefront=self.storefront,
),
{
"term": term,
"types": types,
"limit": limit,
"offset": offset,
},
)
log.debug("success", search_results=search_results)
return search_results
async def get_extended_api_data(
self,
next_uri: str | None,
href_uri: str,
) -> dict:
log = logger.bind(
action="extend_api_data", next_uri=next_uri, href_uri=href_uri
)
if not next_uri:
log.debug("no_next_uri")
return
href_params = parse_qs(urlparse(href_uri).query)
next_params = parse_qs(urlparse(next_uri).query)
if href_params.get("limit"):
limit = int(href_params["limit"][0])
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 {}),
},
)
log.debug("success", extended_data=extended_data)
return extended_data
async def get_webplayback(
self,
track_id: str,
) -> dict:
log = logger.bind(action="get_webplayback", track_id=track_id)
response = None
try:
response = await self.client.post(
APPLE_MUSIC_WEBPLAYBACK_API_URL,
json={
"salableAdamId": track_id,
"language": self.language,
},
)
response.raise_for_status()
webplayback = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching webplayback data",
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:
raise GamdlApiResponseError(
"Error fetching webplayback data",
content=webplayback["dialog"],
)
log.debug("success", webplayback=webplayback)
return webplayback
async def get_license_exchange(
self,
track_id: str,
track_uri: str,
challenge: str,
key_system: str = "com.widevine.alpha",
is_library: bool = False,
) -> 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,
json={
"challenge": challenge,
"key-system": key_system,
"uri": track_uri,
"adamId": track_id,
"isLibrary": is_library,
"user-initiated": True,
},
)
response.raise_for_status()
license_exchange = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching license exchange data",
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:
raise GamdlApiResponseError(
"Error fetching license exchange data",
content=response.text,
status_code=response.status_code,
)
log.debug("success", license_exchange=license_exchange)
return license_exchange
-507
View File
@@ -1,507 +0,0 @@
import logging
import re
import typing
from http.cookiejar import MozillaCookieJar
from urllib.parse import parse_qs, urlparse
import httpx
from ..utils import get_response, raise_for_status, safe_json
from .constants import (
AMP_API_URL,
APPLE_MUSIC_COOKIE_DOMAIN,
APPLE_MUSIC_HOMEPAGE_URL,
LICENSE_API_URL,
WEBPLAYBACK_API_URL,
)
logger = logging.getLogger(__name__)
class AppleMusicApi:
def __init__(
self,
storefront: str = "us",
language: str = "en-US",
media_user_token: str | None = None,
developer_token: str | None = None,
) -> None:
self.storefront = storefront
self.language = language
self.media_user_token = media_user_token
self.token = developer_token
@classmethod
async def create_from_netscape_cookies(
cls,
cookies_path: str = "./cookies.txt",
*args,
**kwargs,
) -> "AppleMusicApi":
cookies = MozillaCookieJar(cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
parse_cookie = lambda name: next(
(
cookie.value
for cookie in cookies
if cookie.name == name and cookie.domain == APPLE_MUSIC_COOKIE_DOMAIN
),
None,
)
media_user_token = parse_cookie("media-user-token")
if not media_user_token:
raise ValueError(
'"media-user-token" cookie not found in cookies. '
"Make sure you have exported the cookies from the Apple Music webpage "
"and are logged in with an active subscription."
)
return await cls.create(
storefront=None,
media_user_token=media_user_token,
developer_token=None,
*args,
**kwargs,
)
@classmethod
async def create_from_wrapper(
cls,
wrapper_account_url: str = "http://127.0.0.1:30020/",
*args,
**kwargs,
) -> "AppleMusicApi":
wrapper_account_response = await get_response(wrapper_account_url)
wrapper_account_info = safe_json(wrapper_account_response)
return await cls.create(
storefront=None,
media_user_token=wrapper_account_info["music_token"],
developer_token=wrapper_account_info["dev_token"],
*args,
**kwargs,
)
@classmethod
async def create(
cls,
storefront: str | None = "us",
language: str = "en-US",
media_user_token: str | None = None,
developer_token: str | None = None,
) -> "AppleMusicApi":
api = cls(
storefront=storefront,
language=language,
media_user_token=media_user_token,
developer_token=developer_token,
)
await api.initialize()
return api
async def initialize(self) -> None:
await self._initialize_client()
await self._initialize_token()
await self._initialize_account_info()
async def _initialize_client(self) -> None:
self.client = httpx.AsyncClient(
headers={
"accept": "*/*",
"accept-language": "en-US",
"origin": APPLE_MUSIC_HOMEPAGE_URL,
"priority": "u=1, i",
"referer": APPLE_MUSIC_HOMEPAGE_URL,
"sec-ch-ua": '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
},
params={
"l": self.language,
},
follow_redirects=True,
timeout=60.0,
)
async def _get_token(self) -> str:
response = await self.client.get(APPLE_MUSIC_HOMEPAGE_URL)
raise_for_status(response)
home_page = response.text
index_js_uri_match = re.search(
r"/(assets/index-legacy[~-][^/\"]+\.js)",
home_page,
)
if not index_js_uri_match:
raise Exception("index.js URI not found in Apple Music homepage")
index_js_uri = index_js_uri_match.group(1)
response = await self.client.get(f"{APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}")
raise_for_status(response)
index_js_page = response.text
token_match = re.search('(?=eyJh)(.*?)(?=")', index_js_page)
if not token_match:
raise Exception("Token not found in index.js page")
token = token_match.group(1)
logger.debug(f"Token: {token}")
return token
async def _initialize_token(self) -> None:
self.token = self.token or await self._get_token()
self.client.headers.update({"authorization": f"Bearer {self.token}"})
async def _initialize_account_info(self) -> None:
if not self.media_user_token:
return
self.client.cookies.update(
{
"media-user-token": self.media_user_token,
}
)
self.account_info = await self.get_account_info()
self.storefront = self.account_info["meta"]["subscription"]["storefront"]
@property
def active_subscription(self) -> bool:
return (
getattr(self, "account_info", {})
.get("meta", {})
.get("subscription", {})
.get("active", False)
)
@property
def account_restrictions(self) -> dict | None:
data = getattr(self, "account_info", {}).get("data", [])
if not data:
return None
return data[0].get("attributes", {}).get("restrictions")
async def get_account_info(self, meta: str | None = "subscription") -> dict:
response = await self.client.get(
f"{AMP_API_URL}/v1/me/account",
params={
**({"meta": meta} if meta else {}),
},
)
raise_for_status(response)
account_info = safe_json(response)
if not "data" in account_info or (meta and "meta" not in account_info):
raise Exception("Error getting account info:", response.text)
logger.debug(f"Account info: {account_info}")
return account_info
async def get_song(
self,
song_id: str,
extend: str = "extendedAssetUrls",
include: str = "lyrics,albums",
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/songs/{song_id}",
params={
"extend": extend,
"include": include,
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
song = safe_json(response)
if not "data" in song:
raise Exception("Error getting song:", response.text)
logger.debug(f"Song: {song}")
return song
async def get_music_video(
self,
music_video_id: str,
include: str = "albums",
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/music-videos/{music_video_id}",
params={
"include": include,
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
music_video = safe_json(response)
if not "data" in music_video:
raise Exception("Error getting music video:", response.text)
logger.debug(f"Music video: {music_video}")
return music_video
async def get_uploaded_video(
self,
post_id: str,
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/uploaded-videos/{post_id}"
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
uploaded_video = safe_json(response)
if not "data" in uploaded_video:
raise Exception("Error getting uploaded video:", response.text)
logger.debug(f"Uploaded video: {uploaded_video}")
return uploaded_video
async def get_album(
self,
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/albums/{album_id}",
params={
"extend": extend,
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
album = safe_json(response)
if not "data" in album:
raise Exception("Error getting album:", response.text)
logger.debug(f"Album: {album}")
return album
async def get_playlist(
self,
playlist_id: str,
limit_tracks: int = 300,
extend: str = "extendedAssetUrls",
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}",
params={
"limit[tracks]": limit_tracks,
"extend": extend,
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
playlist = safe_json(response)
if not "data" in playlist:
raise Exception("Error getting playlist:", response.text)
logger.debug(f"Playlist: {playlist}")
return playlist
async def get_artist(
self,
artist_id: str,
include: str = "albums,music-videos",
limit: int = 100,
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
params={
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
artist = safe_json(response)
if not "data" in artist:
raise Exception("Error getting artist:", response.text)
logger.debug(f"Artist: {artist}")
return artist
async def get_library_album(
self,
album_id: str,
extend: str = "extendedAssetUrls",
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/me/library/albums/{album_id}",
params={
"extend": extend,
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
album = safe_json(response)
if not "data" in album:
raise Exception("Error getting library album:", response.text)
logger.debug(f"Library album: {album}")
return album
async def get_library_playlist(
self,
playlist_id: str,
include: str = "tracks",
limit: int = 100,
extend: str = "extendedAssetUrls",
) -> dict | None:
response = await self.client.get(
f"{AMP_API_URL}/v1/me/library/playlists/{playlist_id}",
params={
"include": include,
**{f"limit[{_include}]": limit for _include in include.split(",")},
"extend": extend,
},
)
raise_for_status(response, {200, 404})
if response.status_code == 404:
return None
playlist = safe_json(response)
if not "data" in playlist:
raise Exception("Error getting library playlist:", response.text)
return playlist
async def get_search_results(
self,
term: str,
types: str = "songs,music-videos,albums,playlists,artists",
limit: int = 50,
offset: int = 0,
) -> dict:
response = await self.client.get(
f"{AMP_API_URL}/v1/catalog/{self.storefront}/search",
params={
"term": term,
"types": types,
"limit": limit,
"offset": offset,
},
)
raise_for_status(response)
search_results = safe_json(response)
if not "results" in search_results:
raise Exception("Error searching:", response.text)
logger.debug(f"Search results: {search_results}")
return search_results
async def extend_api_data(
self,
api_response: dict,
extend: str = "extendedAssetUrls",
) -> typing.AsyncGenerator[dict, None]:
next_uri = api_response.get("next")
if not next_uri:
return
next_uri_params = parse_qs(urlparse(next_uri).query)
limit = int(next_uri_params["offset"][0])
while next_uri:
extended_api_data = await self._get_extended_api_data(
next_uri,
limit,
extend,
)
yield extended_api_data
next_uri = extended_api_data.get("next")
async def _get_extended_api_data(
self,
next_uri: str,
limit: int,
extend: str,
) -> dict:
response = await self.client.get(
AMP_API_URL + next_uri,
params={
"limit": limit,
"extend": extend,
**parse_qs(urlparse(next_uri).query),
},
)
raise_for_status(response)
extended_api_data = safe_json(response)
if not "data" in extended_api_data:
raise Exception("Error getting extended API data:", response.text)
logger.debug(f"Extended API data: {extended_api_data}")
return extended_api_data
async def get_webplayback(
self,
track_id: str,
) -> dict:
response = await self.client.post(
WEBPLAYBACK_API_URL,
json={
"salableAdamId": track_id,
"language": self.language,
},
)
raise_for_status(response)
webplayback = safe_json(response)
if not "songList" in webplayback:
raise Exception("Error getting webplayback:", response.text)
logger.debug(f"Webplayback: {webplayback}")
return webplayback
async def get_license_exchange(
self,
track_id: str,
track_uri: str,
challenge: str,
key_system: str = "com.widevine.alpha",
) -> dict:
response = await self.client.post(
LICENSE_API_URL,
json={
"challenge": challenge,
"key-system": key_system,
"uri": track_uri,
"adamId": track_id,
"isLibrary": False,
"user-initiated": True,
},
)
raise_for_status(response)
license_exchange = safe_json(response)
if not "license" in license_exchange:
raise Exception("Error getting license exchange:", response.text)
logger.debug(f"License exchange: {license_exchange}")
return license_exchange
+26 -162
View File
@@ -1,170 +1,34 @@
APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com"
APPLE_MUSIC_COOKIE_DOMAIN = ".music.apple.com"
AMP_API_URL = "https://amp-api.music.apple.com"
WEBPLAYBACK_API_URL = (
APPLE_MUSIC_AMP_API_URL = "https://amp-api.music.apple.com"
APPLE_MUSIC_ACCOUNT_INFO_API_URI = "/v1/me/account"
APPLE_MUSIC_SONG_API_URI = "/v1/catalog/{storefront}/songs/{song_id}"
APPLE_MUSIC_MUSIC_VIDEO_API_URI = (
"/v1/catalog/{storefront}/music-videos/{music_video_id}"
)
APPLE_MUSIC_UPLOADED_VIDEO_API_URL = (
"/v1/catalog/{storefront}/uploaded-videos/{uploaded_video_id}"
)
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_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_WEBPLAYBACK_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
)
LICENSE_API_URL = (
APPLE_MUSIC_LICENSE_API_URL = (
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense"
)
APPLE_MUSIC_MUSIC_KIT_URL = (
"https://music.apple.com/includes/js-cdn/musickit/v3/amp/musickit.js"
)
ITUNES_LOOKUP_API_URL = "https://itunes.apple.com/lookup"
ITUNES_PAGE_API_URL = "https://music.apple.com"
STOREFRONT_IDS = {
"AE": "143481-2,32",
"AG": "143540-2,32",
"AI": "143538-2,32",
"AL": "143575-2,32",
"AM": "143524-2,32",
"AO": "143564-2,32",
"AR": "143505-28,32",
"AT": "143445-4,32",
"AU": "143460-27,32",
"AZ": "143568-2,32",
"BB": "143541-2,32",
"BE": "143446-2,32",
"BF": "143578-2,32",
"BG": "143526-2,32",
"BH": "143559-2,32",
"BJ": "143576-2,32",
"BM": "143542-2,32",
"BN": "143560-2,32",
"BO": "143556-28,32",
"BR": "143503-15,32",
"BS": "143539-2,32",
"BT": "143577-2,32",
"BW": "143525-2,32",
"BY": "143565-2,32",
"BZ": "143555-2,32",
"CA": "143455-6,32",
"CG": "143582-2,32",
"CH": "143459-57,32",
"CM": "143574-2,32",
"CL": "143483-28,32",
"CN": "143465-19,32",
"CO": "143501-28,32",
"CR": "143495-28,32",
"CV": "143580-2,32",
"CY": "143557-2,32",
"CZ": "143489-2,32",
"DE": "143443-4,32",
"DK": "143458-2,32",
"DM": "143545-2,32",
"DO": "143508-28,32",
"DZ": "143563-2,32",
"EC": "143509-28,32",
"EE": "143518-2,32",
"EG": "143516-2,32",
"ES": "143454-8,32",
"FI": "143447-2,32",
"FJ": "143583-2,32",
"FM": "143591-2,32",
"FR": "143442-3,32",
"GB": "143444-2,32",
"GD": "143546-2,32",
"GH": "143573-2,32",
"GM": "143584-2,32",
"GR": "143448-2,32",
"GT": "143504-28,32",
"GW": "143585-2,32",
"GY": "143553-2,32",
"HK": "143463-45,32",
"HN": "143510-28,32",
"HR": "143494-2,32",
"HU": "143482-2,32",
"ID": "143476-2,32",
"IE": "143449-2,32",
"IL": "143491-2,32",
"IN": "143467-2,32",
"IS": "143558-2,32",
"IT": "143450-7,32",
"JM": "143511-2,32",
"JO": "143528-2,32",
"JP": "143462-9,32",
"KE": "143529-2,32",
"KG": "143586-2,32",
"KH": "143579-2,32",
"KN": "143548-2,32",
"KR": "143466-13,32",
"KW": "143493-2,32",
"KY": "143544-2,32",
"KZ": "143517-2,32",
"LA": "143587-2,32",
"LB": "143497-2,32",
"LC": "143549-2,32",
"LK": "143486-2,32",
"LR": "143588-2,32",
"LT": "143520-2,32",
"LU": "143451-2,32",
"LV": "143519-2,32",
"MD": "143523-2,32",
"MG": "143531-2,32",
"MK": "143530-2,32",
"ML": "143532-2,32",
"MN": "143592-2,32",
"MO": "143515-45,32",
"MR": "143590-2,32",
"MS": "143547-2,32",
"MT": "143521-2,32",
"MU": "143533-2,32",
"MW": "143589-2,32",
"MX": "143468-28,32",
"MY": "143473-2,32",
"MZ": "143593-2,32",
"NA": "143594-2,32",
"NE": "143534-2,32",
"NG": "143561-2,32",
"NI": "143512-28,32",
"NL": "143452-10,32",
"NO": "143457-2,32",
"NP": "143484-2,32",
"NZ": "143461-27,32",
"OM": "143562-2,32",
"PA": "143485-28,32",
"PE": "143507-28,32",
"PG": "143597-2,32",
"PH": "143474-2,32",
"PK": "143477-2,32",
"PL": "143478-2,32",
"PT": "143453-24,32",
"PW": "143595-2,32",
"PY": "143513-28,32",
"QA": "143498-2,32",
"RO": "143487-2,32",
"RU": "143469-16,32",
"SA": "143479-2,32",
"SB": "143601-2,32",
"SC": "143599-2,32",
"SE": "143456-17,32",
"SG": "143464-19,32",
"SI": "143499-2,32",
"SK": "143496-2,32",
"SL": "143600-2,32",
"SN": "143535-2,32",
"SR": "143554-2,32",
"ST": "143598-2,32",
"SV": "143506-28,32",
"SZ": "143602-2,32",
"TC": "143552-2,32",
"TD": "143581-2,32",
"TH": "143475-2,32",
"TJ": "143603-2,32",
"TM": "143604-2,32",
"TN": "143536-2,32",
"TR": "143480-2,32",
"TT": "143551-2,32",
"TW": "143470-18,32",
"TZ": "143572-2,32",
"UA": "143492-2,32",
"UG": "143537-2,32",
"US": "143441-1,32",
"UY": "143514-2,32",
"UZ": "143566-2,32",
"VC": "143550-2,32",
"VE": "143502-28,32",
"VG": "143543-2,32",
"VN": "143471-2,32",
"YE": "143571-2,32",
"ZA": "143472-2,32",
"ZW": "143605-2,32",
}
ITUNES_PAGE_API_URL = "https://music.apple.com/{media_type}/{media_id}"
+25
View File
@@ -0,0 +1,25 @@
from ..utils import GamdlError
class GamdlApiError(GamdlError):
pass
class GamdlApiResponseError(GamdlApiError):
def __init__(
self,
message: str,
content: str | None = None,
status_code: int | None = None,
):
self.message = message
self.content = content
self.status_code = status_code
if status_code is not None:
message = f"{message} (Status code: {status_code})"
if content:
message += f": {content}"
super().__init__(message)
+150
View File
@@ -0,0 +1,150 @@
import re
import httpx
import structlog
from .constants import (
APPLE_MUSIC_MUSIC_KIT_URL,
ITUNES_LOOKUP_API_URL,
ITUNES_PAGE_API_URL,
)
from .exceptions import GamdlApiResponseError
logger = structlog.get_logger(__name__)
class ItunesApi:
def __init__(
self,
client: httpx.AsyncClient,
storefront: str,
language: str,
storefront_id: int,
) -> None:
self.client = client
self.storefront = storefront
self.language = language
self.storefront_id = storefront_id
@staticmethod
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)
response.raise_for_status()
music_kit_content = response.text
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching MusicKit content",
status_code=response.status_code if response is not None else None,
)
normalized_storefront = storefront.upper()
country_code_pattern = f'{normalized_storefront}:"([A-Z]{{3}})"'
country_code_match = re.search(country_code_pattern, music_kit_content)
if not country_code_match:
raise GamdlApiResponseError(
f"Country code {storefront} not found in MusicKit content"
)
three_letter_code = country_code_match.group(1)
storefront_pattern = f'{three_letter_code}:"(\\d+)"'
storefront_match = re.search(storefront_pattern, music_kit_content)
if not storefront_match:
raise GamdlApiResponseError(
f"Storefront ID not found for country code {storefront}"
)
storefront_id = int(storefront_match.group(1))
log.debug("success", storefront_id=storefront_id)
return storefront_id
@classmethod
async def create(
cls,
storefront: str = "us",
storefront_id: int | None = 143441,
language: str = "en-US",
) -> "ItunesApi":
storefront_id = storefront_id or await cls.get_storefront_id(storefront)
client = httpx.AsyncClient(
timeout=60.0,
)
return cls(
client=client,
storefront=storefront,
language=language,
storefront_id=storefront_id,
)
async def get_lookup_result(
self,
media_id: str,
entity: str = "album",
) -> 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,
params={
"id": media_id,
"entity": entity,
"country": self.storefront,
"lang": self.language,
},
)
response.raise_for_status()
lookup_result = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching iTunes lookup result",
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)
return lookup_result
async def get_itunes_page(
self,
media_type: str,
media_id: str,
) -> dict:
log = logger.bind(
action="get_itunes_page",
media_type=media_type,
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",
},
)
response.raise_for_status()
itunes_page = response.json()
except httpx.HTTPError:
raise GamdlApiResponseError(
"Error fetching iTunes page",
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)
return itunes_page
-79
View File
@@ -1,79 +0,0 @@
import logging
import httpx
from ..utils import raise_for_status, safe_json
from .constants import ITUNES_LOOKUP_API_URL, ITUNES_PAGE_API_URL, STOREFRONT_IDS
logger = logging.getLogger(__name__)
class ItunesApi:
def __init__(
self,
storefront: str = "us",
language: str = "en-US",
) -> None:
self.storefront = storefront
self.language = language
self.initialize()
def initialize(self) -> None:
self._initialize_storefront_id()
self._initialize_client()
def _initialize_storefront_id(self) -> None:
try:
self.storefront_id = STOREFRONT_IDS[self.storefront.upper()]
except KeyError:
raise Exception(f"No storefront id for {self.storefront}")
def _initialize_client(self) -> None:
self.client = httpx.AsyncClient(
params={
"country": self.storefront,
"lang": self.language,
},
headers={
"X-Apple-Store-Front": f"{self.storefront_id} t:music31",
},
timeout=60.0,
)
async def get_lookup_result(
self,
media_id: str,
entity: str = "album",
) -> dict:
response = await self.client.get(
ITUNES_LOOKUP_API_URL,
params={
"id": media_id,
"entity": entity,
},
)
raise_for_status(response)
lookup_result = safe_json(response)
if "results" not in lookup_result:
raise Exception("Error getting lookup result:", response.text)
logger.debug(f"Lookup result: {lookup_result}")
return lookup_result
async def get_itunes_page(
self,
media_type: str,
media_id: str,
) -> dict:
response = await self.client.get(
f"{ITUNES_PAGE_API_URL}/{media_type}/{media_id}"
)
raise_for_status(response)
itunes_page = safe_json(response)
if "storePlatformData" not in itunes_page:
raise Exception("Error getting iTunes page:", response.text)
logger.debug(f"iTunes page: {itunes_page}")
return itunes_page
+180 -158
View File
@@ -5,34 +5,42 @@ from pathlib import Path
import click
import colorama
import structlog
from dataclass_click import dataclass_click
from httpx import ConnectError
from .. import __version__
from ..api import AppleMusicApi, ItunesApi
from ..api import AppleMusicApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
AppleMusicMusicVideoDownloader,
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
DownloadItem,
DownloadMode,
GamdlError,
RemuxMode,
GamdlDownloaderDependencyNotFoundError,
GamdlDownloaderMediaFileExistsError,
GamdlDownloaderSyncedLyricsOnlyError,
)
from ..interface import (
AppleMusicBaseInterface,
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
SongCodec,
GamdlInterfaceArtistMediaTypeError,
GamdlInterfaceDecryptionNotAvailableError,
GamdlInterfaceFlatFilterExcludedError,
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceMediaNotStreamableError,
GamdlInterfaceUrlParseError,
)
from .cli_config import CliConfig
from .config_file import ConfigFile
from .constants import X_NOT_IN_PATH
from .utils import CustomLoggerFormatter, prompt_path
from .database import Database
from .interactive_prompts import InteractivePrompts
from .utils import custom_structlog_formatter, prompt_path
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
def make_sync(func):
@@ -47,6 +55,7 @@ def make_sync(func):
@click.help_option("-h", "--help")
@click.version_option(__version__, "-v", "--version")
@dataclass_click(CliConfig)
@ConfigFile.loader
@make_sync
async def main(config: CliConfig):
colorama.just_fix_windows_console()
@@ -56,27 +65,37 @@ async def main(config: CliConfig):
root_logger.propagate = False
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(CustomLoggerFormatter())
stream_handler.setFormatter(logging.Formatter("%(message)s"))
root_logger.addHandler(stream_handler)
if config.log_file:
file_handler = logging.FileHandler(config.log_file, encoding="utf-8")
file_handler.setFormatter(CustomLoggerFormatter(use_colors=False))
file_handler.setFormatter(logging.Formatter("%(message)s"))
root_logger.addHandler(file_handler)
structlog.configure(
processors=[
structlog.processors.add_log_level,
structlog.processors.ExceptionPrettyPrinter(),
custom_structlog_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
)
logger.info(f"Starting Gamdl {__version__}")
if not config.no_config_file:
config_file = ConfigFile(config.config_path)
config_file.cleanup_unknown_params()
config_file.add_params_default_to_config()
config = config_file.update_params_from_config(config)
if config.use_wrapper:
apple_music_api = await AppleMusicApi.create_from_wrapper(
wrapper_account_url=config.wrapper_account_url,
language=config.language,
)
try:
apple_music_api = await AppleMusicApi.create_from_wrapper(
wrapper_account_url=config.wrapper_account_url,
language=config.language,
)
except ConnectError:
logger.critical(
"Could not connect to the wrapper account API. "
"Make sure the wrapper is running and the URL is correct."
)
return
else:
cookies_path = prompt_path(config.cookies_path)
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
@@ -84,130 +103,125 @@ async def main(config: CliConfig):
language=config.language,
)
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
)
if not apple_music_api.active_subscription:
logger.critical(
"No active Apple Music subscription found, you won't be able to download"
" anything"
)
return
if apple_music_api.account_restrictions:
logger.warning(
"Your account has content restrictions enabled, some content may not be"
" downloadable"
)
interface = AppleMusicInterface(
apple_music_api,
itunes_api,
if (
any(not codec.is_legacy() for codec in config.song_codec_piority)
and not config.use_wrapper
):
logger.warning(
"You have chosen an experimental song codec "
"without enabling wrapper. "
"They're not guaranteed to work due to API limitations."
)
if config.database_path:
database = Database(config.database_path)
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,
use_wrapper=config.use_wrapper,
wrapper_m3u8_ip=config.wrapper_m3u8_ip,
wvd_path=config.wvd_path,
)
song_interface = AppleMusicSongInterface(
base=base_interface,
synced_lyrics_format=config.synced_lyrics_format,
codec_priority=config.song_codec_piority,
use_album_date=config.use_album_date,
skip_stream_info=config.synced_lyrics_only,
ask_codec_function=interactive_prompts.ask_song_codec,
)
music_video_interface = AppleMusicMusicVideoInterface(
base=base_interface,
resolution=config.music_video_resolution,
codec_priority=config.music_video_codec_priority,
ask_video_codec_function=interactive_prompts.ask_music_video_video_codec_function,
ask_audio_codec_function=interactive_prompts.ask_music_video_audio_codec_function,
)
uploaded_video_interface = AppleMusicUploadedVideoInterface(
base=base_interface,
quality=config.uploaded_video_quality,
ask_quality_function=interactive_prompts.ask_uploaded_video_quality_function,
)
interface = AppleMusicInterface(
song=song_interface,
music_video=music_video_interface,
uploaded_video=uploaded_video_interface,
artist_select_media_type_function=interactive_prompts.ask_artist_media_type,
artist_select_items_function=interactive_prompts.ask_artist_select_items,
flat_filter_function=flat_filter,
)
song_interface = AppleMusicSongInterface(interface)
music_video_interface = AppleMusicMusicVideoInterface(interface)
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
base_downloader = AppleMusicBaseDownloader(
interface=interface,
output_path=config.output_path,
temp_path=config.temp_path,
wvd_path=config.wvd_path,
overwrite=config.overwrite,
save_cover=config.save_cover,
save_playlist=config.save_playlist,
nm3u8dlre_path=config.nm3u8dlre_path,
mp4decrypt_path=config.mp4decrypt_path,
ffmpeg_path=config.ffmpeg_path,
mp4box_path=config.mp4box_path,
amdecrypt_path=config.amdecrypt_path,
use_wrapper=config.use_wrapper,
wrapper_decrypt_ip=config.wrapper_decrypt_ip,
download_mode=config.download_mode,
remux_mode=config.remux_mode,
cover_format=config.cover_format,
album_folder_template=config.album_folder_template,
compilation_folder_template=config.compilation_folder_template,
no_album_folder_template=config.no_album_folder_template,
playlist_folder_template=config.playlist_folder_template,
single_disc_file_template=config.single_disc_file_template,
multi_disc_file_template=config.multi_disc_file_template,
no_album_file_template=config.no_album_file_template,
playlist_file_template=config.playlist_file_template,
date_tag_template=config.date_tag_template,
exclude_tags=config.exclude_tags,
cover_size=config.cover_size,
truncate=config.truncate,
)
song_downloader = AppleMusicSongDownloader(
base_downloader=base_downloader,
interface=song_interface,
codec=config.song_codec,
synced_lyrics_format=config.synced_lyrics_format,
no_synced_lyrics=config.no_synced_lyrics,
synced_lyrics_only=config.synced_lyrics_only,
use_album_date=config.use_album_date,
fetch_extra_tags=config.fetch_extra_tags,
base=base_downloader,
)
music_video_downloader = AppleMusicMusicVideoDownloader(
base_downloader=base_downloader,
interface=music_video_interface,
codec_priority=config.music_video_codec_priority,
base=base_downloader,
remux_mode=config.music_video_remux_mode,
remux_format=config.music_video_remux_format,
resolution=config.music_video_resolution,
)
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
base_downloader=base_downloader,
interface=uploaded_video_interface,
quality=config.uploaded_video_quality,
base=base_downloader,
)
downloader = AppleMusicDownloader(
interface=interface,
base_downloader=base_downloader,
song_downloader=song_downloader,
music_video_downloader=music_video_downloader,
uploaded_video_downloader=uploaded_video_downloader,
song=song_downloader,
music_video=music_video_downloader,
uploaded_video=uploaded_video_downloader,
overwrite=config.overwrite,
save_cover=config.save_cover,
save_playlist=config.save_playlist,
no_synced_lyrics=config.no_synced_lyrics,
synced_lyrics_only=config.synced_lyrics_only,
)
if not config.synced_lyrics_only:
if not base_downloader.full_ffmpeg_path and (
config.remux_mode == RemuxMode.FFMPEG
or config.download_mode == DownloadMode.NM3U8DLRE
):
logger.critical(X_NOT_IN_PATH.format("ffmpeg", config.ffmpeg_path))
return
if (
not base_downloader.full_mp4box_path
and config.remux_mode == RemuxMode.MP4BOX
):
logger.critical(X_NOT_IN_PATH.format("MP4Box", config.mp4box_path))
return
if not base_downloader.full_mp4decrypt_path and (
config.song_codec not in (SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY)
or config.remux_mode == RemuxMode.MP4BOX
):
logger.critical(X_NOT_IN_PATH.format("mp4decrypt", config.mp4decrypt_path))
return
if (
config.download_mode == DownloadMode.NM3U8DLRE
and not base_downloader.full_nm3u8dlre_path
):
logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", config.nm3u8dlre_path))
return
if config.use_wrapper and not base_downloader.full_amdecrypt_path:
logger.critical(X_NOT_IN_PATH.format("amdecrypt", config.amdecrypt_path))
return
if not config.song_codec.is_legacy() and not config.use_wrapper:
logger.warning(
"You have chosen an experimental song codec"
" without enabling wrapper."
"They're not guaranteed to work due to API limitations."
)
if config.read_urls_as_txt:
urls_from_file = []
for url in config.urls:
@@ -225,68 +239,76 @@ async def main(config: CliConfig):
error_count = 0
for url_index, url in enumerate(urls, 1):
url_progress = click.style(f"[URL {url_index}/{len(urls)}]", dim=True)
logger.info(url_progress + f' Processing "{url}"')
download_queue = None
url_log = logger.bind(action=f"URL {url_index:>3}/{len(urls):<3}")
url_log.info(f'Processing "{url}"')
try:
url_info = downloader.get_url_info(url)
if not url_info:
logger.warning(
url_progress + f' Could not parse "{url}", skipping.',
)
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 "-"
download_queue = await downloader.get_download_queue(url_info)
if not download_queue:
logger.warning(
url_progress
+ f' No downloadable media found for "{url}", skipping.',
track_log = logger.bind(
action=f"Track {media_index:>3}/{media_total:<3}"
)
continue
except KeyboardInterrupt:
exit(1)
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
logger.error(
url_progress + f' Error processing "{url}"',
exc_info=not config.no_exceptions,
)
if not download_queue:
continue
for download_index, download_item in enumerate(
download_queue,
1,
):
download_queue_progress = click.style(
f"[Track {download_index}/{len(download_queue)}]",
dim=True,
)
media_title = (
download_item.media_metadata["attributes"]["name"]
if isinstance(
download_item,
DownloadItem,
)
else "Unknown Title"
)
logger.info(download_queue_progress + f' Downloading "{media_title}"')
try:
await downloader.download(download_item)
except GamdlError as e:
logger.warning(
download_queue_progress + f' Skipping "{media_title}": {e}'
)
continue
except KeyboardInterrupt:
exit(1)
except Exception as e:
error_count += 1
logger.error(
download_queue_progress + f' Error downloading "{media_title}"',
exc_info=not config.no_exceptions,
)
logger.info(f"Finished with {error_count} error(s)")
+208 -212
View File
@@ -4,20 +4,24 @@ from pathlib import Path
from typing import Annotated
import click
from click.types import BoolParamType, FuncParamType, IntParamType, StringParamType
from dataclass_click import argument, option
from ..api import AppleMusicApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
AppleMusicMusicVideoDownloader,
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
DownloadMode,
RemuxFormatMusicVideo,
RemuxMode,
)
from ..interface import (
AppleMusicBaseInterface,
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
ArtistMediaType,
CoverFormat,
MusicVideoCodec,
MusicVideoResolution,
@@ -29,13 +33,19 @@ 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)
api_sig = inspect.signature(AppleMusicApi.__init__)
api_create_sig = inspect.signature(AppleMusicApi.create)
base_interface_create_sig = inspect.signature(AppleMusicBaseInterface.create)
song_interface_sig = inspect.signature(AppleMusicSongInterface.__init__)
music_video_interface_sig = inspect.signature(AppleMusicMusicVideoInterface.__init__)
uploaded_video_interface_sig = inspect.signature(
AppleMusicUploadedVideoInterface.__init__
)
interface_create_sig = inspect.signature(AppleMusicInterface)
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
song_downloader_sig = inspect.signature(AppleMusicSongDownloader.__init__)
uploaded_video_downloader_sig = inspect.signature(
AppleMusicUploadedVideoDownloader.__init__
)
downloader_sig = inspect.signature(AppleMusicDownloader.__init__)
@dataclass
@@ -55,8 +65,6 @@ class CliConfig:
"--read-urls-as-txt",
"-r",
help="Read URLs from text files",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
@@ -102,8 +110,38 @@ class CliConfig:
option(
"--no-exceptions",
help="Don't print exceptions",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
artist_auto_select: Annotated[
ArtistMediaType | None,
option(
"--artist-auto-select",
help="Automatically select artist content to download (only for artist URLs)",
default=None,
type=ArtistMediaType,
),
]
database_path: Annotated[
str,
option(
"--database-path",
help="Path to the SQLite database file for registering downloaded media",
default=None,
type=click.Path(
file_okay=True,
dir_okay=False,
writable=True,
resolve_path=True,
),
),
]
no_config_file: Annotated[
bool,
option(
"--no-config-file",
"-n",
help="Don't use a config file",
is_flag=True,
),
]
@@ -129,7 +167,6 @@ class CliConfig:
"--wrapper-account-url",
help="Wrapper account URL",
default=api_from_wrapper_sig.parameters["wrapper_account_url"].default,
type=StringParamType(),
),
]
language: Annotated[
@@ -138,8 +175,111 @@ class CliConfig:
"--language",
"-l",
help="Metadata language",
default=api_sig.parameters["language"].default,
type=StringParamType(),
default=api_create_sig.parameters["language"].default,
),
]
# Base Interface specific options
cover_format: Annotated[
CoverFormat,
option(
"--cover-format",
help="Cover format",
default=base_interface_create_sig.parameters["cover_format"].default,
type=CoverFormat,
),
]
cover_size: Annotated[
int,
option(
"--cover-size",
help="Cover size in pixels",
default=base_interface_create_sig.parameters["cover_size"].default,
),
]
wvd_path: Annotated[
str | None,
option(
"--wvd-path",
help=".wvd file path",
default=base_interface_create_sig.parameters["wvd_path"].default,
type=click.Path(
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
),
),
]
use_wrapper: Annotated[
bool,
option(
"--use-wrapper",
help="Use wrapper for decrypting songs",
is_flag=True,
),
]
wrapper_m3u8_ip: Annotated[
str,
option(
"--wrapper-m3u8-ip",
help="Wrapper m3u8 IP address and port",
default=base_interface_create_sig.parameters["wrapper_m3u8_ip"].default,
),
]
# Song Interface Options
synced_lyrics_format: Annotated[
SyncedLyricsFormat,
option(
"--synced-lyrics-format",
help="Synced lyrics format",
default=song_interface_sig.parameters["synced_lyrics_format"].default,
type=SyncedLyricsFormat,
),
]
song_codec_piority: Annotated[
list[SongCodec],
option(
"--song-codec-priority",
help="Comma-separated codec priority",
default=song_interface_sig.parameters["codec_priority"].default,
type=Csv(SongCodec),
),
]
use_album_date: Annotated[
bool,
option(
"--use-album-date",
help="Use album release date for songs",
is_flag=True,
),
]
# Music Video Interface Options
music_video_resolution: Annotated[
MusicVideoResolution,
option(
"--music-video-resolution",
help="Max music video resolution",
default=music_video_interface_sig.parameters["resolution"].default,
type=MusicVideoResolution,
),
]
music_video_codec_priority: Annotated[
list[MusicVideoCodec],
option(
"--music-video-codec-priority",
help="Comma-separated codec priority",
default=music_video_interface_sig.parameters["codec_priority"].default,
type=Csv(MusicVideoCodec),
),
]
# Uploaded Video Interface Options
uploaded_video_quality: Annotated[
UploadedVideoQuality,
option(
"--uploaded-video-quality",
help="Post video quality",
default=uploaded_video_interface_sig.parameters["quality"].default,
type=UploadedVideoQuality,
),
]
# Base Downloader specific options
@@ -172,58 +312,12 @@ class CliConfig:
),
),
]
wvd_path: Annotated[
str,
option(
"--wvd-path",
help=".wvd file path",
default=base_downloader_sig.parameters["wvd_path"].default,
type=click.Path(
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
),
),
]
overwrite: Annotated[
bool,
option(
"--overwrite",
help="Overwrite existing files",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
save_cover: Annotated[
bool,
option(
"--save-cover",
"-s",
help="Save cover as separate file",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
save_playlist: Annotated[
bool,
option(
"--save-playlist",
help="Save M3U8 playlist file",
type=BoolParamType(),
default=False,
is_flag=True,
),
]
nm3u8dlre_path: Annotated[
str,
option(
"--nm3u8dlre-path",
help="N_m3u8DL-RE executable path",
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
type=StringParamType(),
),
]
mp4decrypt_path: Annotated[
@@ -232,7 +326,6 @@ class CliConfig:
"--mp4decrypt-path",
help="mp4decrypt executable path",
default=base_downloader_sig.parameters["mp4decrypt_path"].default,
type=StringParamType(),
),
]
ffmpeg_path: Annotated[
@@ -241,7 +334,6 @@ class CliConfig:
"--ffmpeg-path",
help="FFmpeg executable path",
default=base_downloader_sig.parameters["ffmpeg_path"].default,
type=StringParamType(),
),
]
mp4box_path: Annotated[
@@ -250,26 +342,6 @@ class CliConfig:
"--mp4box-path",
help="MP4Box executable path",
default=base_downloader_sig.parameters["mp4box_path"].default,
type=StringParamType(),
),
]
amdecrypt_path: Annotated[
str,
option(
"--amdecrypt-path",
help="amdecrypt executable path",
default=base_downloader_sig.parameters["amdecrypt_path"].default,
type=StringParamType(),
),
]
use_wrapper: Annotated[
bool,
option(
"--use-wrapper",
help="Use wrapper and amdecrypt for decrypting songs",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
wrapper_decrypt_ip: Annotated[
@@ -278,7 +350,6 @@ class CliConfig:
"--wrapper-decrypt-ip",
help="IP address and port for wrapper decryption",
default=base_downloader_sig.parameters["wrapper_decrypt_ip"].default,
type=StringParamType(),
),
]
download_mode: Annotated[
@@ -287,25 +358,7 @@ class CliConfig:
"--download-mode",
help="Download mode",
default=base_downloader_sig.parameters["download_mode"].default,
type=FuncParamType(DownloadMode),
),
]
remux_mode: Annotated[
RemuxMode,
option(
"--remux-mode",
help="Remux mode",
default=base_downloader_sig.parameters["remux_mode"].default,
type=FuncParamType(RemuxMode),
),
]
cover_format: Annotated[
CoverFormat,
option(
"--cover-format",
help="Cover format",
default=base_downloader_sig.parameters["cover_format"].default,
type=FuncParamType(CoverFormat),
type=DownloadMode,
),
]
album_folder_template: Annotated[
@@ -314,7 +367,6 @@ class CliConfig:
"--album-folder-template",
help="Album folder template",
default=base_downloader_sig.parameters["album_folder_template"].default,
type=StringParamType(),
),
]
compilation_folder_template: Annotated[
@@ -325,7 +377,6 @@ class CliConfig:
default=base_downloader_sig.parameters[
"compilation_folder_template"
].default,
type=StringParamType(),
),
]
no_album_folder_template: Annotated[
@@ -334,7 +385,14 @@ class CliConfig:
"--no-album-folder-template",
help="No album folder template",
default=base_downloader_sig.parameters["no_album_folder_template"].default,
type=StringParamType(),
),
]
playlist_folder_template: Annotated[
str,
option(
"--playlist-folder-template",
help="Playlist folder template",
default=base_downloader_sig.parameters["playlist_folder_template"].default,
),
]
single_disc_file_template: Annotated[
@@ -343,7 +401,6 @@ class CliConfig:
"--single-disc-file-template",
help="Single disc file template",
default=base_downloader_sig.parameters["single_disc_file_template"].default,
type=StringParamType(),
),
]
multi_disc_file_template: Annotated[
@@ -352,7 +409,6 @@ class CliConfig:
"--multi-disc-file-template",
help="Multi disc file template",
default=base_downloader_sig.parameters["multi_disc_file_template"].default,
type=StringParamType(),
),
]
no_album_file_template: Annotated[
@@ -361,7 +417,6 @@ class CliConfig:
"--no-album-file-template",
help="No album file template",
default=base_downloader_sig.parameters["no_album_file_template"].default,
type=StringParamType(),
),
]
playlist_file_template: Annotated[
@@ -370,7 +425,6 @@ class CliConfig:
"--playlist-file-template",
help="Playlist file template",
default=base_downloader_sig.parameters["playlist_file_template"].default,
type=StringParamType(),
),
]
date_tag_template: Annotated[
@@ -379,7 +433,6 @@ class CliConfig:
"--date-tag-template",
help="Date tag template",
default=base_downloader_sig.parameters["date_tag_template"].default,
type=StringParamType(),
),
]
exclude_tags: Annotated[
@@ -391,91 +444,22 @@ class CliConfig:
type=Csv(str),
),
]
cover_size: Annotated[
int,
option(
"--cover-size",
help="Cover size in pixels",
default=base_downloader_sig.parameters["cover_size"].default,
type=IntParamType(),
),
]
truncate: Annotated[
int,
option(
"--truncate",
help="Max filename length",
default=base_downloader_sig.parameters["truncate"].default,
type=IntParamType(),
),
]
# DownloaderSong specific options
song_codec: Annotated[
SongCodec,
option(
"--song-codec",
help="Song codec",
default=song_downloader_sig.parameters["codec"].default,
type=FuncParamType(SongCodec),
),
]
synced_lyrics_format: Annotated[
SyncedLyricsFormat,
option(
"--synced-lyrics-format",
help="Synced lyrics format",
default=song_downloader_sig.parameters["synced_lyrics_format"].default,
type=FuncParamType(SyncedLyricsFormat),
),
]
no_synced_lyrics: Annotated[
bool,
option(
"--no-synced-lyrics",
help="Don't download synced lyrics",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
synced_lyrics_only: Annotated[
bool,
option(
"--synced-lyrics-only",
help="Download only synced lyrics",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
use_album_date: Annotated[
bool,
option(
"--use-album-date",
help="Use album release date for songs",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
fetch_extra_tags: Annotated[
bool,
option(
"--fetch-extra-tags",
help="Fetch extra tags from preview (normalization and smooth playback)",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
# DownloaderMusicVideo specific options
music_video_codec_priority: Annotated[
list[MusicVideoCodec],
music_video_remux_mode: Annotated[
RemuxMode,
option(
"--music-video-codec-priority",
help="Comma-separated codec priority",
default=music_video_downloader_sig.parameters["codec_priority"].default,
type=Csv(MusicVideoCodec),
"--music-video-remux-mode",
help="Remux mode",
default=music_video_downloader_sig.parameters["remux_mode"].default,
type=RemuxMode,
),
]
music_video_remux_format: Annotated[
@@ -484,36 +468,48 @@ class CliConfig:
"--music-video-remux-format",
help="Music video remux format",
default=music_video_downloader_sig.parameters["remux_format"].default,
type=FuncParamType(RemuxFormatMusicVideo),
type=RemuxFormatMusicVideo,
),
]
music_video_resolution: Annotated[
MusicVideoResolution,
option(
"--music-video-resolution",
help="Max music video resolution",
default=music_video_downloader_sig.parameters["resolution"].default,
type=FuncParamType(MusicVideoResolution),
),
]
# DownloaderUploadedVideo specific options
uploaded_video_quality: Annotated[
UploadedVideoQuality,
option(
"--uploaded-video-quality",
help="Post video quality",
default=uploaded_video_downloader_sig.parameters["quality"].default,
type=FuncParamType(UploadedVideoQuality),
),
]
no_config_file: Annotated[
# Downloader specific options
overwrite: Annotated[
bool,
option(
"--no-config-file",
"-n",
help="Don't use a config file",
default=False,
type=BoolParamType(),
"--overwrite",
help="Overwrite existing files",
is_flag=True,
),
]
save_cover: Annotated[
bool,
option(
"--save-cover",
"-s",
help="Save cover as separate file",
is_flag=True,
),
]
save_playlist: Annotated[
bool,
option(
"--save-playlist",
help="Save M3U8 playlist file",
is_flag=True,
),
]
no_synced_lyrics: Annotated[
bool,
option(
"--no-synced-lyrics",
help="Don't download synced lyrics",
is_flag=True,
),
]
synced_lyrics_only: Annotated[
bool,
option(
"--synced-lyrics-only",
help="Download only synced lyrics",
is_flag=True,
),
]
+64 -71
View File
@@ -1,25 +1,16 @@
import configparser
import typing
from dataclasses import dataclass
from functools import wraps
from pathlib import Path
from typing import get_type_hints
import click
import click.types as click_types
from dataclass_click.dataclass_click import _DelayedCall
from .cli_config import CliConfig
from .constants import EXCLUDED_CONFIG_FILE_PARAMS
from .utils import Csv
@dataclass
class ParameterInfo:
name: str
default: typing.Any
type: typing.Any
class ConfigFile:
def __init__(
self,
@@ -28,34 +19,10 @@ class ConfigFile:
) -> None:
self.config_path = config_path
self.section_name = section_name
self.parameters = self._extract_parameters_from_cli_config()
self.click_context = click.get_current_context()
self._read_config_file()
def _extract_parameters_from_cli_config(self) -> dict[str, ParameterInfo]:
parameters = {}
hints = get_type_hints(CliConfig, include_extras=True)
for field_name, hint in hints.items():
if hasattr(hint, "__metadata__"):
for metadata in hint.__metadata__:
if isinstance(metadata, _DelayedCall):
param_type = metadata.kwargs.get("type")
if param_type is None:
raise ValueError(
f"Parameter type for field '{field_name}' "
"could not be determined."
)
parameters[field_name] = ParameterInfo(
name=field_name,
default=metadata.kwargs.get("default"),
type=param_type,
)
break
return parameters
def _read_config_file(self) -> None:
self.config = configparser.ConfigParser(interpolation=None)
@@ -71,81 +38,81 @@ class ConfigFile:
with open(self.config_path, "w", encoding="utf-8") as config_file:
self.config.write(config_file)
def _serialize_param_default(self, param_info: ParameterInfo) -> str:
if param_info.default is None:
def _serialize_param_default(self, param: click.Parameter) -> str:
if param.default is None:
return "null"
if isinstance(param_info.type, Csv):
if isinstance(param.type, Csv):
return ",".join(
item.value if hasattr(item, "value") else str(item)
for item in param_info.default
for item in param.default
)
if isinstance(param_info.type, click_types.FuncParamType):
return param_info.default.value
if isinstance(param.type, click_types.FuncParamType):
return param.default.value
if isinstance(param_info.type, click_types.BoolParamType):
return "true" if param_info.default else "false"
if isinstance(param.type, click_types.BoolParamType):
return "true" if param.default else "false"
if isinstance(
param_info.type,
param.type,
click_types.Choice
| click_types.Path
| click_types.StringParamType
| click_types.IntParamType,
):
return str(param_info.default)
return str(param.default)
raise NotImplementedError(
f"Serialization for parameter '{param_info.name}' of type "
f"'{type(param_info.type)}' is not implemented."
f"Serialization for parameter '{param.name}' of type "
f"'{type(param.type)}' is not implemented."
)
def _add_param_default_to_config(
self,
param_info: ParameterInfo,
param: click.Parameter,
) -> bool:
if self.config.has_option(self.section_name, param_info.name):
if self.config.has_option(self.section_name, param.name):
return False
value = self._serialize_param_default(param_info)
self.config.set(self.section_name, param_info.name, value)
value = self._serialize_param_default(param)
self.config.set(self.section_name, param.name, value)
return True
def _parse_param_from_config(
self,
param_info: ParameterInfo,
param: click.Parameter,
) -> typing.Any:
value = self.config[self.section_name].get(param_info.name)
value = self.config[self.section_name].get(param.name)
if value is None:
return param_info.default
return param.default
if value == "null":
return None
if not isinstance(param_info.type, click_types.ParamType):
if not isinstance(param.type, click_types.ParamType):
raise NotImplementedError(
f"Parsing for parameter '{param_info.name}' of type "
f"'{type(param_info.type)}' is not implemented."
f"Parsing for parameter '{param.name}' of type "
f"'{type(param.type)}' is not implemented."
)
return param_info.type.convert(value, None, None)
return param.type.convert(value, None, None)
def add_params_default_to_config(self) -> None:
has_changes = False
for param_info in self.parameters.values():
if param_info.name in EXCLUDED_CONFIG_FILE_PARAMS:
for param in self.click_context.command.params:
if param.name in EXCLUDED_CONFIG_FILE_PARAMS:
continue
has_changes = self._add_param_default_to_config(param_info) or has_changes
has_changes = self._add_param_default_to_config(param) or has_changes
if has_changes:
self._write_config_file()
def cleanup_unknown_params(self) -> None:
param_names = {info.name for info in self.parameters.values()}
param_names = {info.name for info in self.click_context.command.params}
has_changes = False
for key in list(self.config[self.section_name].keys()):
@@ -156,19 +123,45 @@ class ConfigFile:
if has_changes:
self._write_config_file()
def update_params_from_config(self, config: CliConfig) -> CliConfig:
updates = {}
click_context = click.get_current_context()
for param_info in self.parameters.values():
def update_params_from_config(self) -> None:
for param in self.click_context.command.params:
if (
click_context.get_parameter_source(param_info.name)
self.click_context.get_parameter_source(param.name)
== click.core.ParameterSource.COMMANDLINE
):
continue
if self.config.has_option(self.section_name, param_info.name):
updates[param_info.name] = self._parse_param_from_config(param_info)
if self.config.has_option(self.section_name, param.name):
self.click_context.params[param.name] = self._parse_param_from_config(
param
)
config_dict = config.__dict__.copy()
config_dict.update(updates)
def get_cli_config(self) -> CliConfig:
config_dict = {}
for param in self.click_context.command.params:
if param.name in {"help", "version"}:
continue
config_dict[param.name] = self.click_context.params.get(
param.name, param.default
)
return CliConfig(**config_dict)
def load(self) -> CliConfig:
self.cleanup_unknown_params()
self.add_params_default_to_config()
self.update_params_from_config()
return self.get_cli_config()
@staticmethod
def loader(func):
@wraps(func)
def wrapper(cli_config: CliConfig):
ctx = click.get_current_context()
config_path = ctx.params.get("config_path")
no_config_file = ctx.params.get("no_config_file")
if config_path and not no_config_file:
cli_config = ConfigFile(config_path).load()
return func(cli_config)
return wrapper
+48
View File
@@ -0,0 +1,48 @@
import sqlite3
from pathlib import Path
class Database:
def __init__(self, path: Path):
self.connection = sqlite3.connect(path)
self.cursor = self.connection.cursor()
self._create_tables()
def _create_tables(self) -> None:
self.cursor.execute(
"""
CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY,
path TEXT NOT NULL
)
"""
)
self.connection.commit()
def get(self, media_id: str) -> str | None:
self.cursor.execute("SELECT path FROM media WHERE id = ?", (media_id,))
row = self.cursor.fetchone()
return row[0] if row else None
def add(self, media_id: str, path: str) -> None:
self.cursor.execute(
"INSERT OR REPLACE INTO media (id, path) VALUES (?, ?)",
(media_id, str(Path(path).absolute())),
)
self.connection.commit()
def remove(self, media_id: str) -> None:
self.cursor.execute("DELETE FROM media WHERE id = ?", (media_id,))
self.connection.commit()
def close(self) -> None:
self.connection.close()
def flat_filter(self, media_metadata: dict) -> str | None:
media_id = media_metadata["id"]
result = self.get(media_id)
if not result:
return None
return result if Path(result).exists() else None
+232
View File
@@ -0,0 +1,232 @@
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
import m3u8
from ..interface import ArtistMediaType
class InteractivePrompts:
def __init__(
self,
artist_auto_select: ArtistMediaType | None = None,
):
self.artist_auto_select = artist_auto_select
@staticmethod
def millis_to_min_sec(millis) -> str:
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02}:{seconds:02}"
@staticmethod
async def ask_song_codec(
playlists: list[dict],
) -> dict:
choices = [
Choice(
name=playlist["stream_info"]["audio"],
value=playlist,
)
for playlist in playlists
]
return await inquirer.select(
message="Select which codec to download:",
choices=choices,
).execute_async()
@staticmethod
async def ask_music_video_video_codec_function(
playlists: list[m3u8.Playlist],
) -> dict:
choices = [
Choice(
name=" | ".join(
[
playlist.stream_info.codecs[:4],
"x".join(str(v) for v in playlist.stream_info.resolution),
str(playlist.stream_info.bandwidth),
]
),
value=playlist,
)
for playlist in playlists
]
return await inquirer.select(
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
choices=choices,
).execute_async()
@staticmethod
async def ask_music_video_audio_codec_function(
playlists: list[dict],
) -> dict:
choices = [
Choice(
name=playlist["group_id"],
value=playlist,
)
for playlist in playlists
]
selected = await inquirer.select(
message="Select which audio codec to download:",
choices=choices,
).execute_async()
return selected
@staticmethod
async def ask_uploaded_video_quality_function(
available_qualities: dict[str, str],
) -> str:
qualities = list(available_qualities.keys())
choices = [
Choice(
name=quality,
value=quality,
)
for quality in qualities
]
selected = await inquirer.select(
message="Select which quality to download:",
choices=choices,
).execute_async()
return available_qualities[selected]
async def ask_artist_media_type(
self,
media_types: list[ArtistMediaType],
artist_metadata: dict,
) -> ArtistMediaType:
if self.artist_auto_select:
return self.artist_auto_select
available_choices = []
for media_types in media_types:
available_choices.append(
Choice(
name=str(media_types),
value=(media_types,),
),
)
(media_type,) = await inquirer.select(
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
choices=available_choices,
validate=lambda result: artist_metadata.get(result[0].path_key[0], {})
.get(result[0].path_key[1], {})
.get("data"),
).execute_async()
return media_type
async def ask_artist_select_items(
self,
media_type: ArtistMediaType,
items: list[dict],
) -> list[dict]:
if media_type in {
ArtistMediaType.MAIN_ALBUMS,
ArtistMediaType.COMPILATION_ALBUMS,
ArtistMediaType.LIVE_ALBUMS,
ArtistMediaType.SINGLES_EPS,
ArtistMediaType.ALL_ALBUMS,
}:
return await self._ask_artist_select_albums(items)
elif media_type == ArtistMediaType.TOP_SONGS:
return await self._ask_artist_select_songs(
items,
)
elif media_type == ArtistMediaType.MUSIC_VIDEOS:
return await self._ask_artist_select_music_videos(items)
async def _ask_artist_select_albums(
self,
albums: list[dict],
) -> list[dict]:
if self.artist_auto_select:
return albums
choices = [
Choice(
name=" | ".join(
[
f'{album["attributes"]["trackCount"]:03d}',
f'{album["attributes"]["releaseDate"]:<10}',
f'{album["attributes"].get("contentRating", "None").title():<8}',
f'{album["attributes"]["name"]}',
]
),
value=album,
)
for album in albums
if album.get("attributes")
]
selected = await inquirer.select(
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
return selected
async def _ask_artist_select_songs(
self,
songs: list[dict],
) -> list[dict]:
if self.artist_auto_select:
return songs
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(song["attributes"]["durationInMillis"]),
f'{song["attributes"].get("contentRating", "None").title():<8}',
song["attributes"]["name"],
],
),
value=song,
)
for song in songs
if song.get("attributes")
]
selected = await inquirer.select(
message="Select which songs to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
return selected
async def _ask_artist_select_music_videos(
self,
music_videos: list[dict],
) -> list[dict]:
if self.artist_auto_select:
return music_videos
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(
music_video["attributes"]["durationInMillis"]
),
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
music_video["attributes"]["name"],
],
),
value=music_video,
)
for music_video in music_videos
if music_video.get("attributes")
]
selected = await inquirer.select(
message="Select which music videos to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
return selected
+27 -23
View File
@@ -1,6 +1,7 @@
import logging
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any
import click
@@ -38,31 +39,34 @@ class Csv(click.ParamType):
return result
class CustomLoggerFormatter(logging.Formatter):
base_format = "[%(levelname)-8s %(asctime)s]"
format_colors = {
logging.DEBUG: dict(dim=True),
logging.INFO: dict(fg="green"),
logging.WARNING: dict(fg="yellow"),
logging.ERROR: dict(fg="red"),
logging.CRITICAL: dict(fg="red", bold=True),
def custom_structlog_formatter(
logger: Any,
name: str,
event_dict: dict[str, Any],
) -> str:
level = event_dict.get("level", "INFO").upper()
timestamp = datetime.now().strftime("%H:%M:%S")
level_colors = {
"DEBUG": "cyan",
"INFO": "green",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "red",
}
date_format = "%H:%M:%S"
def __init__(self, use_colors: bool = True) -> None:
super().__init__()
self.use_colors = use_colors
color = level_colors.get(level, "white")
prefix = click.style(f"[{level:<8} {timestamp}]", fg=color)
def format(self, record: logging.LogRecord) -> str:
return logging.Formatter(
(
click.style(self.base_format, **self.format_colors.get(record.levelno))
if self.use_colors
else self.base_format
)
+ " %(message)s",
datefmt=self.date_format,
).format(record)
action = event_dict.pop("action", None)
if action:
prefix += click.style(f" [{action}]", dim=True)
if level in {"INFO", "WARNING", "ERROR", "CRITICAL"}:
message = event_dict.get("event", "")
return f"{prefix} {message}"
else:
return f"{prefix} {event_dict}"
def prompt_path(
+5 -4
View File
@@ -1,8 +1,9 @@
from .amdecrypt import decrypt_file, decrypt_file_hex
from .base import AppleMusicBaseDownloader
from .downloader import AppleMusicDownloader
from .downloader_base import AppleMusicBaseDownloader
from .downloader_music_video import AppleMusicMusicVideoDownloader
from .downloader_song import AppleMusicSongDownloader
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
from .enums import *
from .exceptions import *
from .music_video import AppleMusicMusicVideoDownloader
from .song import AppleMusicSongDownloader
from .types import *
from .uploaded_video import AppleMusicUploadedVideoDownloader
File diff suppressed because it is too large Load Diff
@@ -1,127 +1,85 @@
import asyncio
import re
import shutil
import uuid
from pathlib import Path
import structlog
from mutagen.mp4 import MP4, MP4Cover
from pywidevine import Cdm, Device
from yt_dlp import YoutubeDL
from ..interface.enums import CoverFormat
from ..interface.interface import AppleMusicInterface
from ..interface.types import MediaTags, PlaylistTags
from ..utils import CustomStringFormatter, async_subprocess
from .constants import ILLEGAL_CHAR_REPLACEMENT, ILLEGAL_CHARS_RE, TEMP_PATH_TEMPLATE
from .enums import DownloadMode, RemuxMode
from .hardcoded_wvd import HARDCODED_WVD
from .enums import DownloadMode
logger = structlog.get_logger(__name__)
class AppleMusicBaseDownloader:
def __init__(
self,
interface: AppleMusicInterface,
output_path: str = "./Apple Music",
temp_path: str = ".",
wvd_path: str = None,
overwrite: bool = False,
save_cover: bool = False,
save_playlist: bool = False,
nm3u8dlre_path: str = "N_m3u8DL-RE",
mp4decrypt_path: str = "mp4decrypt",
ffmpeg_path: str = "ffmpeg",
mp4box_path: str = "MP4Box",
amdecrypt_path: str = "amdecrypt",
use_wrapper: bool = False,
wrapper_decrypt_ip: str = "127.0.0.1:10020",
download_mode: DownloadMode = DownloadMode.YTDLP,
remux_mode: RemuxMode = RemuxMode.FFMPEG,
cover_format: CoverFormat = CoverFormat.JPG,
album_folder_template: str = "{album_artist}/{album}",
compilation_folder_template: str = "Compilations/{album}",
no_album_folder_template: str = "{artist}/Unknown Album",
playlist_folder_template: str = "Playlists/{playlist_artist}",
single_disc_file_template: str = "{track:02d} {title}",
multi_disc_file_template: str = "{disc}-{track:02d} {title}",
no_album_file_template: str = "{title}",
playlist_file_template: str = "Playlists/{playlist_artist}/{playlist_title}",
playlist_file_template: str = "{playlist_title}",
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
exclude_tags: list[str] = None,
cover_size: int = 1200,
truncate: int = None,
silent: bool = False,
):
self.interface = interface
self.output_path = output_path
self.temp_path = temp_path
self.wvd_path = wvd_path
self.overwrite = overwrite
self.save_cover = save_cover
self.save_playlist = save_playlist
self.nm3u8dlre_path = nm3u8dlre_path
self.mp4decrypt_path = mp4decrypt_path
self.ffmpeg_path = ffmpeg_path
self.mp4box_path = mp4box_path
self.amdecrypt_path = amdecrypt_path
self.use_wrapper = use_wrapper
self.wrapper_decrypt_ip = wrapper_decrypt_ip
self.download_mode = download_mode
self.remux_mode = remux_mode
self.cover_format = cover_format
self.album_folder_template = album_folder_template
self.compilation_folder_template = compilation_folder_template
self.no_album_folder_template = no_album_folder_template
self.single_disc_file_template = single_disc_file_template
self.multi_disc_file_template = multi_disc_file_template
self.playlist_folder_template = playlist_folder_template
self.no_album_file_template = no_album_file_template
self.playlist_file_template = playlist_file_template
self.date_tag_template = date_tag_template
self.exclude_tags = exclude_tags
self.cover_size = cover_size
self.truncate = truncate
self.silent = silent
self.initialize()
def initialize(self):
self._initialize_binary_paths()
self._initialize_cdm()
def _initialize_binary_paths(self):
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)
self.full_amdecrypt_path = shutil.which(self.amdecrypt_path)
def _initialize_cdm(self):
if self.wvd_path:
self.cdm = Cdm.from_device(Device.load(self.wvd_path))
else:
self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
self.cdm.MAX_NUM_OF_SESSIONS = float("inf")
def get_random_uuid(self) -> str:
return uuid.uuid4().hex[:8]
def is_media_streamable(
self,
media_metadata: dict,
) -> bool:
return bool(media_metadata["attributes"].get("playParams"))
def get_playlist_tags(
self,
playlist_metadata: dict,
media_metadata: dict,
) -> PlaylistTags:
playlist_track = (
playlist_metadata["relationships"]["tracks"]["data"].index(media_metadata)
+ 1
)
return PlaylistTags(
playlist_artist=playlist_metadata["attributes"].get(
"curatorName", "Unknown"
),
playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
playlist_title=playlist_metadata["attributes"]["name"],
playlist_track=playlist_track,
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(
@@ -131,13 +89,19 @@ class AppleMusicBaseDownloader:
file_tag: str,
file_extension: str,
) -> str:
return str(
log = logger.bind(action="get_temp_path")
temp_path = str(
Path(self.temp_path)
/ TEMP_PATH_TEMPLATE.format(folder_tag)
/ (f"{media_id}_{file_tag}" + file_extension)
)
def sanitize_string(
log.debug("success", temp_path=temp_path)
return temp_path
def _sanitize_string(
self,
dirty_string: str,
file_ext: str = None,
@@ -165,6 +129,8 @@ class AppleMusicBaseDownloader:
file_extension: str,
playlist_tags: PlaylistTags | None,
) -> str:
log = logger.bind(action="get_final_path")
if tags.album:
template_folder_parts = (
self.compilation_folder_template.split("/")
@@ -202,7 +168,7 @@ class AppleMusicBaseDownloader:
disc_total=(tags.disc_total, ""),
media_type=(tags.media_type, "Unknown Media Type"),
playlist_artist=(
(playlist_tags.playlist_artist if playlist_tags else None),
(playlist_tags.artist if playlist_tags else None),
"Unknown Playlist Artist",
),
playlist_id=(
@@ -210,11 +176,11 @@ class AppleMusicBaseDownloader:
"Unknown Playlist ID",
),
playlist_title=(
(playlist_tags.playlist_title if playlist_tags else None),
(playlist_tags.title if playlist_tags else None),
"Unknown Playlist Title",
),
playlist_track=(
(playlist_tags.playlist_track if playlist_tags else None),
(playlist_tags.track if playlist_tags else None),
"",
),
title=(tags.title, "Unknown Title"),
@@ -222,29 +188,39 @@ class AppleMusicBaseDownloader:
track=(tags.track, ""),
track_total=(tags.track_total, ""),
)
sanitized_formatted_part = self.sanitize_string(
sanitized_formatted_part = self._sanitize_string(
formatted_part,
file_extension if not is_folder else None,
)
formatted_parts.append(sanitized_formatted_part)
return str(Path(self.output_path, *formatted_parts))
final_path = str(Path(self.output_path, *formatted_parts))
log.debug("success", final_path=final_path)
return final_path
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(stream_url, download_path)
await self._download_ytdlp_async(stream_url, download_path)
if self.download_mode == DownloadMode.NM3U8DLRE:
await self.download_nm3u8dlre(stream_url, download_path)
await self._download_nm3u8dlre(stream_url, download_path)
async def download_ytdlp(self, stream_url: str, download_path: str) -> None:
log.debug("success")
async def _download_ytdlp_async(self, stream_url: str, download_path: str) -> None:
await asyncio.to_thread(
self._download_ytdlp,
self._download_ytdlp_sync,
stream_url,
download_path,
)
def _download_ytdlp(self, stream_url: str, download_path: str) -> None:
def _download_ytdlp_sync(self, stream_url: str, download_path: str) -> None:
with YoutubeDL(
{
"quiet": True,
@@ -259,7 +235,7 @@ class AppleMusicBaseDownloader:
) as ydl:
ydl.download(stream_url)
async def download_nm3u8dlre(self, stream_url: str, download_path: str):
async def _download_nm3u8dlre(self, stream_url: str, download_path: str):
download_path_obj = Path(download_path)
download_path_obj.parent.mkdir(parents=True, exist_ok=True)
@@ -283,11 +259,12 @@ class AppleMusicBaseDownloader:
async def apply_tags(
self,
media_path: Path,
media_path: str,
tags: MediaTags,
cover_bytes: bytes | None,
extra_tags: dict | None = None,
):
log = logger.bind(action="apply_tags", media_path=media_path)
exclude_tags = self.exclude_tags or []
filtered_tags = MediaTags(
@@ -302,21 +279,21 @@ class AppleMusicBaseDownloader:
skip_tagging = "all" in exclude_tags
await asyncio.to_thread(
self.apply_mp4_tags,
self._apply_mp4_tags,
media_path,
mp4_tags,
cover_bytes,
skip_tagging,
extra_tags,
)
def apply_mp4_tags(
log.debug("success")
def _apply_mp4_tags(
self,
media_path: Path,
media_path: str,
tags: dict,
cover_bytes: bytes | None,
skip_tagging: bool,
extra_tags: dict | None,
):
mp4 = MP4(media_path)
mp4.clear()
@@ -328,14 +305,12 @@ class AppleMusicBaseDownloader:
data=cover_bytes,
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
if self.interface.base.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
mp4.update(tags)
if extra_tags:
mp4.update(extra_tags)
mp4.save()
@@ -352,82 +327,41 @@ class AppleMusicBaseDownloader:
data=cover_bytes,
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
if self.interface.base.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
def move_to_final_path(self, stage_path: str, final_path: str) -> None:
Path(final_path).parent.mkdir(parents=True, exist_ok=True)
shutil.move(stage_path, final_path)
def write_cover_image(
self,
cover_bytes: bytes,
cover_path: str,
) -> None:
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
Path(cover_path).write_bytes(cover_bytes)
def get_playlist_file_path(
self,
tags: PlaylistTags,
) -> str:
log = logger.bind(action="get_playlist_file_path")
template_folder_parts = self.playlist_folder_template.split("/")
template_file_parts = self.playlist_file_template.split("/")
template_parts = template_folder_parts + template_file_parts
formatted_parts = []
for i, part in enumerate(template_file_parts):
is_folder = i < len(template_file_parts) - 1
for i, part in enumerate(template_parts):
is_folder = i < len(template_parts) - 1
formatted_part = CustomStringFormatter().format(
part,
playlist_artist=(tags.playlist_artist, "Unknown Playlist Artist"),
playlist_artist=(tags.artist, "Unknown Playlist Artist"),
playlist_id=(tags.playlist_id, "Unknown Playlist ID"),
playlist_title=(tags.playlist_title, "Unknown Playlist Title"),
playlist_track=(tags.playlist_track, ""),
playlist_title=(tags.title, "Unknown Playlist Title"),
playlist_track=(tags.track, ""),
)
file_ext = None if is_folder else ".m3u8"
sanitized_formatted_part = self.sanitize_string(
file_ext = None if is_folder else ".m3u"
sanitized_formatted_part = self._sanitize_string(
formatted_part,
file_ext,
)
formatted_parts.append(sanitized_formatted_part)
return str(Path(self.output_path, *formatted_parts))
final_path = str(Path(self.output_path, *formatted_parts))
def update_playlist_file(
self,
playlist_file_path: str,
final_path: str,
playlist_track: int,
) -> None:
playlist_file_path_obj = Path(playlist_file_path)
final_path_obj = Path(final_path)
output_dir_obj = Path(self.output_path)
log.debug("success", playlist_file_path=final_path)
playlist_file_path_obj.parent.mkdir(parents=True, exist_ok=True)
playlist_file_path_parent_parts_len = len(playlist_file_path_obj.parent.parts)
output_path_parts_len = len(output_dir_obj.parts)
final_path_relative = Path(
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
*final_path_obj.parts[output_path_parts_len:],
)
playlist_file_lines = (
playlist_file_path_obj.open("r", encoding="utf8").readlines()
if playlist_file_path_obj.exists()
else []
)
if len(playlist_file_lines) < playlist_track:
playlist_file_lines.extend(
"\n" for _ in range(playlist_track - len(playlist_file_lines))
)
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
with playlist_file_path_obj.open("w", encoding="utf8") as playlist_file:
playlist_file.writelines(playlist_file_lines)
def cleanup_temp(self, random_uuid: str) -> None:
temp_folder = Path(self.temp_path) / TEMP_PATH_TEMPLATE.format(random_uuid)
if temp_folder.exists():
shutil.rmtree(temp_folder)
return final_path
-25
View File
@@ -1,28 +1,3 @@
import re
DEFAULT_SONG_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
TEMP_PATH_TEMPLATE = "gamdl_temp_{}"
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
ILLEGAL_CHAR_REPLACEMENT = "_"
SONG_MEDIA_TYPE = {"song", "songs", "library-songs"}
ALBUM_MEDIA_TYPE = {"album", "albums", "library-albums"}
MUSIC_VIDEO_MEDIA_TYPE = {"music-video", "music-videos", "library-music-videos"}
ARTIST_MEDIA_TYPE = {"artist", "artists", "library-artists"}
UPLOADED_VIDEO_MEDIA_TYPE = {"post", "uploaded-videos"}
PLAYLIST_MEDIA_TYPE = {"playlist", "playlists", "library-playlists"}
VALID_URL_PATTERN = re.compile(
r"https://music\.apple\.com"
r"(?:"
r"/(?P<storefront>[a-z]{2})"
r"/(?P<type>artist|album|playlist|song|music-video|post)"
r"(?:/(?P<slug>[^\s/]+))?"
r"/(?P<id>[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]+)"
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")"
)
+222 -485
View File
@@ -1,531 +1,268 @@
import asyncio
import typing
import shutil
from pathlib import Path
from typing import AsyncGenerator
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
import structlog
from ..interface import AppleMusicInterface
from ..utils import safe_gather
from .constants import (
ALBUM_MEDIA_TYPE,
ARTIST_MEDIA_TYPE,
MUSIC_VIDEO_MEDIA_TYPE,
PLAYLIST_MEDIA_TYPE,
SONG_MEDIA_TYPE,
UPLOADED_VIDEO_MEDIA_TYPE,
VALID_URL_PATTERN,
)
from .downloader_base import AppleMusicBaseDownloader
from .downloader_music_video import AppleMusicMusicVideoDownloader
from .downloader_song import AppleMusicSongDownloader
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
from ..interface.types import AppleMusicMedia
from .constants import TEMP_PATH_TEMPLATE
from .enums import DownloadMode, RemuxMode
from .exceptions import (
ExecutableNotFound,
FormatNotAvailable,
MediaFileExists,
NotStreamable,
SyncedLyricsOnly,
UnsupportedMediaType,
GamdlDownloaderDependencyNotFoundError,
GamdlDownloaderMediaFileExistsError,
GamdlDownloaderSyncedLyricsOnlyError,
)
from .types import DownloadItem, UrlInfo
from .music_video import AppleMusicMusicVideoDownloader
from .song import AppleMusicSongDownloader
from .types import DownloadItem
from .uploaded_video import AppleMusicUploadedVideoDownloader
logger = structlog.get_logger(__name__)
class AppleMusicDownloader:
def __init__(
self,
interface: AppleMusicInterface,
base_downloader: AppleMusicBaseDownloader,
song_downloader: AppleMusicSongDownloader,
music_video_downloader: AppleMusicMusicVideoDownloader,
uploaded_video_downloader: AppleMusicUploadedVideoDownloader,
skip_music_videos: bool = False,
song: AppleMusicSongDownloader,
music_video: AppleMusicMusicVideoDownloader,
uploaded_video: AppleMusicUploadedVideoDownloader,
overwrite: bool = False,
save_cover: bool = False,
save_playlist: bool = False,
no_synced_lyrics: bool = False,
synced_lyrics_only: bool = False,
skip_cleanup: bool = False,
skip_processing: bool = False,
flat_filter: typing.Callable = None,
):
self.interface = interface
self.base_downloader = base_downloader
self.song_downloader = song_downloader
self.music_video_downloader = music_video_downloader
self.uploaded_video_downloader = uploaded_video_downloader
self.skip_music_videos = skip_music_videos
self.song = song
self.music_video = music_video
self.uploaded_video = uploaded_video
self.overwrite = overwrite
self.save_cover = save_cover
self.save_playlist = save_playlist
self.no_synced_lyrics = no_synced_lyrics
self.synced_lyrics_only = synced_lyrics_only
self.skip_cleanup = skip_cleanup
self.skip_processing = skip_processing
self.flat_filter = flat_filter
async def get_single_download_item(
self.base = song.base
async def get_download_item_from_url(
self,
media_metadata: dict,
playlist_metadata: dict = None,
url: str,
) -> AsyncGenerator[DownloadItem, None]:
async for media in self.base.interface.get_media_from_url(url):
yield await self.parse_download_item(media)
async def parse_download_item(
self,
media: AppleMusicMedia,
) -> DownloadItem:
if self.flat_filter:
flat_filter_result = self.flat_filter(media_metadata)
if asyncio.iscoroutine(flat_filter_result):
flat_filter_result = await flat_filter_result
if media.error:
return DownloadItem(media)
if flat_filter_result:
return DownloadItem(
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
flat_filter_result=flat_filter_result,
)
if media.partial:
return DownloadItem(media)
return await self.get_single_download_item_no_filter(
media_metadata,
playlist_metadata,
)
elif media.media_metadata["type"] in {"songs", "library-songs"}:
return await self.song.get_download_item(media)
async def get_single_download_item_no_filter(
self,
media_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
try:
if not self.base_downloader.is_media_streamable(
media_metadata,
):
raise NotStreamable(media_metadata["id"])
if media_metadata["type"] in SONG_MEDIA_TYPE:
if not self.song_downloader:
raise UnsupportedMediaType(media_metadata["type"])
download_item = await self.song_downloader.get_download_item(
media_metadata,
playlist_metadata,
)
if media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
if not self.music_video_downloader:
raise UnsupportedMediaType(media_metadata["type"])
download_item = await self.music_video_downloader.get_download_item(
media_metadata,
playlist_metadata,
)
if media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
if not self.uploaded_video_downloader:
raise UnsupportedMediaType(media_metadata["type"])
download_item = await self.uploaded_video_downloader.get_download_item(
media_metadata,
)
except Exception as e:
download_item = DownloadItem(
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
error=e,
)
return download_item
async def get_collection_download_items(
self,
collection_metadata: dict,
) -> list[DownloadItem]:
tracks_metadata = collection_metadata["relationships"]["tracks"]["data"]
async for extended_data in self.interface.apple_music_api.extend_api_data(
collection_metadata["relationships"]["tracks"],
):
tracks_metadata.extend(extended_data["data"])
tasks = [
self.get_single_download_item(
media_metadata,
(
collection_metadata
if collection_metadata["type"] in PLAYLIST_MEDIA_TYPE
else None
),
)
for media_metadata in tracks_metadata
]
download_items = await safe_gather(*tasks)
return download_items
async def get_artist_download_items(
self,
artist_metadata: dict,
) -> list[DownloadItem]:
for relationship in artist_metadata["relationships"].keys():
artist_metadata["relationships"][relationship]["data"].extend(
[
extended_data
async for extended_data in self.interface.apple_music_api.extend_api_data(
artist_metadata["relationships"][relationship],
)
]
)
media_type = await inquirer.select(
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
choices=[
Choice(
name="Albums",
value="albums",
),
Choice(
name="Music Videos",
value="music-videos",
),
],
validate=lambda result: artist_metadata["relationships"]
.get(result, {})
.get("data"),
invalid_message="The artist doesn't have any items of this type",
).execute_async()
if media_type == "albums":
return await self.get_artist_albums_download_items(
artist_metadata["relationships"]["albums"]["data"]
)
if media_type == "music-videos":
return await self.get_artist_music_videos_download_items(
artist_metadata["relationships"]["music-videos"]["data"]
)
async def get_artist_albums_download_items(
self,
albums_metadata: list[dict],
) -> list[DownloadItem]:
choices = [
Choice(
name=" | ".join(
[
f'{album["attributes"]["trackCount"]:03d}',
f'{album["attributes"]["releaseDate"]:<10}',
f'{album["attributes"].get("contentRating", "None").title():<8}',
f'{album["attributes"]["name"]}',
]
),
value=album,
)
for album in albums_metadata
if album.get("attributes")
]
selected = await inquirer.select(
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
download_items = []
album_tasks = [
self.interface.apple_music_api.get_album(album_metadata["id"])
for album_metadata in selected
]
album_responses = await safe_gather(*album_tasks)
track_tasks = [
self.get_collection_download_items(album_response["data"][0])
for album_response in album_responses
]
track_results = await safe_gather(*track_tasks)
for track_result in track_results:
download_items.extend(track_result)
return download_items
async def get_artist_music_videos_download_items(
self,
music_videos_metadata: list[dict],
) -> list[DownloadItem]:
choices = [
Choice(
name=" | ".join(
[
self.millis_to_min_sec(
music_video["attributes"]["durationInMillis"]
),
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
music_video["attributes"]["name"],
],
),
value=music_video,
)
for music_video in music_videos_metadata
if music_video.get("attributes")
]
selected = await inquirer.select(
message="Select which music videos to download: (Duration | Rating | Title)",
choices=choices,
multiselect=True,
).execute_async()
music_video_tasks = [
self.get_single_download_item(
music_video_metadata,
)
for music_video_metadata in selected
]
download_items = await safe_gather(*music_video_tasks)
return download_items
def millis_to_min_sec(self, millis) -> str:
minutes, seconds = divmod(millis // 1000, 60)
return f"{minutes:02}:{seconds:02}"
def get_url_info(self, url: str) -> UrlInfo | None:
match = VALID_URL_PATTERN.match(url)
if not match:
return None
return UrlInfo(
**match.groupdict(),
)
async def get_download_queue(
self,
url_info: UrlInfo,
) -> list[DownloadItem] | None:
return await self._get_download_queue(
"song" if url_info.sub_id else url_info.type or url_info.library_type,
url_info.sub_id or url_info.id or url_info.library_id,
url_info.library_id is not None,
)
async def _get_download_queue(
self,
url_type: str,
id: str,
is_library: bool,
) -> list[DownloadItem] | None:
download_items = []
if url_type in ARTIST_MEDIA_TYPE:
artist_response = await self.interface.apple_music_api.get_artist(
id,
)
if artist_response is None:
return None
download_items = await self.get_artist_download_items(
artist_response["data"][0],
)
if url_type in SONG_MEDIA_TYPE:
song_respose = await self.interface.apple_music_api.get_song(id)
if song_respose is None:
return None
download_items.append(
await self.get_single_download_item(song_respose["data"][0])
)
if url_type in ALBUM_MEDIA_TYPE:
if is_library:
album_response = await self.interface.apple_music_api.get_library_album(
id
)
else:
album_response = await self.interface.apple_music_api.get_album(id)
if album_response is None:
return None
download_items = await self.get_collection_download_items(
album_response["data"][0],
)
if url_type in PLAYLIST_MEDIA_TYPE:
if is_library:
playlist_response = (
await self.interface.apple_music_api.get_library_playlist(id)
)
else:
playlist_response = await self.interface.apple_music_api.get_playlist(
id
)
if playlist_response is None:
return None
download_items = await self.get_collection_download_items(
playlist_response["data"][0],
)
if url_type in MUSIC_VIDEO_MEDIA_TYPE:
music_video_response = await self.interface.apple_music_api.get_music_video(
id
)
if music_video_response is None:
return None
download_items.append(
await self.get_single_download_item(music_video_response["data"][0])
)
if url_type in UPLOADED_VIDEO_MEDIA_TYPE:
uploaded_video = await self.interface.apple_music_api.get_uploaded_video(id)
if uploaded_video is None:
return None
download_items.append(
await self.get_single_download_item(uploaded_video["data"][0])
)
return download_items
async def download(
self,
download_item: DownloadItem,
) -> DownloadItem:
try:
if download_item.flat_filter_result:
download_item = await self.get_single_download_item_no_filter(
download_item.media_metadata,
download_item.playlist_metadata,
)
if download_item.error:
raise download_item.error
await self._initial_processing(download_item)
await self._download(download_item)
await self._final_processing(download_item)
return download_item
finally:
if isinstance(download_item, DownloadItem) and not self.skip_processing:
self.base_downloader.cleanup_temp(download_item.random_uuid)
async def _download(
self,
download_item: DownloadItem,
) -> None:
if (
self.song_downloader.synced_lyrics_only
and download_item.media_metadata["type"] not in SONG_MEDIA_TYPE
):
raise SyncedLyricsOnly()
if self.song_downloader.synced_lyrics_only:
return
if (
Path(download_item.final_path).exists()
and not self.base_downloader.overwrite
):
raise MediaFileExists(download_item.final_path)
if download_item.media_metadata["type"] in {
*SONG_MEDIA_TYPE,
*MUSIC_VIDEO_MEDIA_TYPE,
elif media.media_metadata["type"] in {
"music-videos",
"library-music-videos",
}:
if (
self.base_downloader.remux_mode == RemuxMode.FFMPEG
and not self.base_downloader.full_ffmpeg_path
):
raise ExecutableNotFound("ffmpeg")
return await self.music_video.get_download_item(media)
if (
self.base_downloader.remux_mode == RemuxMode.MP4BOX
and not self.base_downloader.full_mp4box_path
):
raise ExecutableNotFound("MP4Box")
elif media.media_metadata["type"] in {"uploaded-videos"}:
return await self.uploaded_video.get_download_item(media)
if (
self.song_downloader.use_wrapper
or (
download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
or self.base_downloader.remux_mode == RemuxMode.MP4BOX
)
) and not self.base_downloader.full_mp4decrypt_path:
raise ExecutableNotFound("mp4decrypt")
async def download(self, item: DownloadItem) -> None:
try:
if item.media.error:
raise item.media.error
if (
self.song_downloader.use_wrapper
and not self.base_downloader.full_amdecrypt_path
):
raise ExecutableNotFound("amdecrypt")
if item.media.partial:
return
if (
self.base_downloader.download_mode == DownloadMode.NM3U8DLRE
and not self.base_downloader.full_nm3u8dlre_path
):
raise ExecutableNotFound("N_m3u8DL-RE")
await self._initial_processing(item)
await self._download(item)
await self._final_processing(item)
finally:
self._cleanup_temp(item.uuid_)
if (
not download_item.stream_info
or not download_item.stream_info.audio_track
or not download_item.stream_info.audio_track.stream_url
or (
(
not download_item.decryption_key
or not download_item.decryption_key.audio_track
or not download_item.decryption_key.audio_track.key
)
and not self.base_downloader.use_wrapper
)
):
raise FormatNotAvailable(download_item.media_metadata["id"])
if download_item.media_metadata["type"] in SONG_MEDIA_TYPE:
await self.song_downloader.download(download_item)
if download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
await self.music_video_downloader.download(download_item)
if download_item.media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
await self.uploaded_video_downloader.download(download_item)
async def _initial_processing(
def _update_playlist_file(
self,
download_item: DownloadItem,
playlist_file_path: str,
final_path: str,
playlist_track: int,
) -> None:
log = logger.bind(
action="update_playlist_file",
playlist_file_path=playlist_file_path,
final_path=final_path,
playlist_track=playlist_track,
)
playlist_file_path_obj = Path(playlist_file_path)
final_path_obj = Path(final_path)
output_dir_obj = Path(self.base.output_path)
playlist_file_path_obj.parent.mkdir(parents=True, exist_ok=True)
playlist_file_path_parent_parts_len = len(playlist_file_path_obj.parent.parts)
output_path_parts_len = len(output_dir_obj.parts)
final_path_relative = Path(
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
*final_path_obj.parts[output_path_parts_len:],
)
playlist_file_lines = (
playlist_file_path_obj.open("r", encoding="utf8").readlines()
if playlist_file_path_obj.exists()
else []
)
if len(playlist_file_lines) < playlist_track:
playlist_file_lines.extend(
"\n" for _ in range(playlist_track - len(playlist_file_lines))
)
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
with playlist_file_path_obj.open("w", encoding="utf8") as playlist_file:
playlist_file.writelines(playlist_file_lines)
log.debug("success")
def _write_cover(self, cover_path: str, cover_bytes: bytes) -> None:
log = logger.bind(action="write_cover_file", cover_path=cover_path)
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
with open(cover_path, "wb") as f:
f.write(cover_bytes)
log.debug("success")
def _write_synced_lyrics(self, synced_lyrics_path: str, lyrics: str) -> None:
log = logger.bind(
action="write_synced_lyrics",
synced_lyrics_path=synced_lyrics_path,
)
Path(synced_lyrics_path).parent.mkdir(parents=True, exist_ok=True)
with open(synced_lyrics_path, "w", encoding="utf-8") as f:
f.write(lyrics)
log.debug("success")
async def _initial_processing(self, item: DownloadItem) -> None:
if self.skip_processing:
return
if download_item.cover_path and self.base_downloader.save_cover:
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
if cover_bytes and (
self.base_downloader.overwrite
or not Path(download_item.cover_path).exists()
):
self.base_downloader.write_cover_image(
if item.playlist_file_path and item.final_path and self.save_playlist:
self._update_playlist_file(
item.playlist_file_path,
item.final_path,
item.media.playlist_tags.track,
)
if item.cover_path and self.save_cover and item.media.cover.url:
cover_bytes = await self.base.interface.base.get_cover_bytes(
item.media.cover.url,
)
if cover_bytes and (self.overwrite or not Path(item.cover_path).exists()):
self._write_cover(
item.cover_path,
cover_bytes,
download_item.cover_path,
)
if (
download_item.lyrics
and download_item.lyrics.synced
and not self.song_downloader.no_synced_lyrics
and (
self.base_downloader.overwrite
or not Path(download_item.synced_lyrics_path).exists()
)
item.synced_lyrics_path
and not self.no_synced_lyrics
and item.media.lyrics
and item.media.lyrics.synced
and (self.overwrite or not Path(item.synced_lyrics_path).exists())
):
self.song_downloader.write_synced_lyrics(
download_item.lyrics.synced,
download_item.synced_lyrics_path,
self._write_synced_lyrics(
item.synced_lyrics_path,
item.media.lyrics.synced,
)
if download_item.playlist_tags and self.base_downloader.save_playlist:
self.base_downloader.update_playlist_file(
download_item.playlist_file_path,
download_item.final_path,
download_item.playlist_tags.playlist_track,
)
async def _download(self, item: DownloadItem) -> None:
if item.media.error:
raise item.media.error
if self.synced_lyrics_only:
raise GamdlDownloaderSyncedLyricsOnlyError()
if Path(item.final_path).exists() and not self.overwrite:
raise GamdlDownloaderMediaFileExistsError(item.final_path)
if item.media.media_metadata["type"] in {
"music-videos",
"library-music-videos",
"songs",
"library-songs",
}:
if (
self.base.download_mode == DownloadMode.NM3U8DLRE
and not self.base.full_nm3u8dlre_path
):
raise GamdlDownloaderDependencyNotFoundError("N_m3u8DL-RE")
if item.media.media_metadata["type"] in {"songs", "library-songs"}:
await self.song.download(item)
elif item.media.media_metadata["type"] in {
"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"}:
await self.uploaded_video.download(item)
def _move_to_final_path(self, staged_path: str, final_path: str) -> None:
log = logger.bind(
action="move_to_final_path",
staged_path=staged_path,
final_path=final_path,
)
Path(final_path).parent.mkdir(parents=True, exist_ok=True)
shutil.move(staged_path, final_path)
log.debug("success")
async def _final_processing(
self,
download_item: DownloadItem,
item: DownloadItem,
) -> None:
if self.skip_processing:
return
if download_item.staged_path and Path(download_item.staged_path).exists():
self.base_downloader.move_to_final_path(
download_item.staged_path,
download_item.final_path,
if Path(item.staged_path).exists():
self._move_to_final_path(
item.staged_path,
item.final_path,
)
def _cleanup_temp(self, folder_tag: str) -> None:
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:
shutil.rmtree(temp_path, ignore_errors=True)
log.debug("success")
-279
View File
@@ -1,279 +0,0 @@
from pathlib import Path
from ..interface.enums import MusicVideoCodec, MusicVideoResolution
from ..interface.interface_music_video import AppleMusicMusicVideoInterface
from ..interface.types import DecryptionKeyAv
from ..utils import async_subprocess
from .downloader_base import AppleMusicBaseDownloader
from .enums import RemuxFormatMusicVideo, RemuxMode
from .types import DownloadItem
class AppleMusicMusicVideoDownloader(AppleMusicBaseDownloader):
def __init__(
self,
base_downloader: AppleMusicBaseDownloader,
interface: AppleMusicMusicVideoInterface,
codec_priority: list[MusicVideoCodec] = [
MusicVideoCodec.H264,
MusicVideoCodec.H265,
],
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
):
self.__dict__.update(base_downloader.__dict__)
self.interface = interface
self.codec_priority = codec_priority
self.remux_format = remux_format
self.resolution = resolution
async def remux_mp4box(
self,
input_path_video: str,
input_path_audio: str,
output_path: str,
):
await async_subprocess(
self.full_mp4box_path,
"-quiet",
"-add",
input_path_audio,
"-add",
input_path_video,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
output_path,
silent=self.silent,
)
async def remux_ffmpeg(
self,
input_path_video: str,
input_path_audio: str,
output_path: str,
decryption_key: str = None,
):
if decryption_key:
key = [
"-decryption_key",
decryption_key,
]
else:
key = []
await async_subprocess(
self.full_ffmpeg_path,
"-loglevel",
"error",
"-y",
*key,
"-i",
input_path_video,
"-i",
input_path_audio,
"-c",
"copy",
"-c:s",
"mov_text",
"-movflags",
"+faststart",
output_path,
silent=self.silent,
)
async def decrypt_mp4decrypt(
self,
input_path: str,
output_path: str,
decryption_key: str,
):
await async_subprocess(
self.full_mp4decrypt_path,
"--key",
f"1:{decryption_key}",
input_path,
output_path,
silent=self.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,
):
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,
decryption_key.audio_track.key,
)
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,
final_path: str,
file_extension: str,
) -> str:
return str(Path(final_path).with_suffix(file_extension))
async def get_download_item(
self,
music_video_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
download_item = DownloadItem()
download_item.media_metadata = music_video_metadata
download_item.playlist_metadata = playlist_metadata
music_video_id = self.interface.get_media_id_of_library_media(
music_video_metadata,
)
itunes_page_metadata = await self.interface.get_itunes_page_metadata(
music_video_metadata,
)
download_item.media_tags = await self.interface.get_tags(
music_video_metadata,
itunes_page_metadata,
)
if playlist_metadata:
download_item.playlist_tags = self.get_playlist_tags(
playlist_metadata,
music_video_metadata,
)
download_item.playlist_file_path = self.get_playlist_file_path(
download_item.playlist_tags,
)
download_item.stream_info = await self.interface.get_stream_info(
music_video_metadata,
itunes_page_metadata,
self.codec_priority,
self.resolution,
)
download_item.decryption_key = await self.interface.get_decryption_key(
download_item.stream_info,
self.cdm,
)
download_item.random_uuid = self.get_random_uuid()
download_item.staged_path = self.get_temp_path(
music_video_id,
download_item.random_uuid,
"staged",
(
"."
+ (
"mp4"
if self.remux_format == RemuxFormatMusicVideo.MP4
else download_item.stream_info.file_format.value
)
),
)
download_item.final_path = self.get_final_path(
download_item.media_tags,
Path(download_item.staged_path).suffix,
playlist_metadata,
)
download_item.cover_url_template = self.interface.get_cover_url_template(
music_video_metadata,
self.cover_format,
)
download_item.cover_url = self.interface.get_cover_url(
download_item.cover_url_template,
self.cover_size,
self.cover_format,
)
cover_file_extension = await self.interface.get_cover_file_extension(
download_item.cover_url,
self.cover_format,
)
if cover_file_extension:
download_item.cover_path = self.get_cover_path(
download_item.final_path,
cover_file_extension,
)
return download_item
async def download(
self,
download_item: DownloadItem,
) -> None:
encrypted_path_video = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"encrypted_video",
".mp4",
)
encrypted_path_audio = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"encrypted_audio",
".m4a",
)
await self.download_stream(
download_item.stream_info.video_track.stream_url,
encrypted_path_video,
)
await self.download_stream(
download_item.stream_info.audio_track.stream_url,
encrypted_path_audio,
)
decrypted_path_video = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"decrypted_video",
".mp4",
)
decrypted_path_audio = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_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.decryption_key,
)
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
cover_bytes,
)
-347
View File
@@ -1,347 +0,0 @@
from pathlib import Path
from ..interface.enums import SongCodec, SyncedLyricsFormat
from ..interface.interface_song import AppleMusicSongInterface
from ..interface.types import DecryptionKeyAv
from ..utils import async_subprocess
from .constants import DEFAULT_SONG_DECRYPTION_KEY
from .downloader_base import AppleMusicBaseDownloader
from .enums import RemuxMode
from .types import DownloadItem
class AppleMusicSongDownloader(AppleMusicBaseDownloader):
def __init__(
self,
base_downloader: AppleMusicBaseDownloader,
interface: AppleMusicSongInterface,
codec: SongCodec = SongCodec.AAC_LEGACY,
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
no_synced_lyrics: bool = False,
synced_lyrics_only: bool = False,
use_album_date: bool = False,
fetch_extra_tags: bool = False,
):
self.__dict__.update(base_downloader.__dict__)
self.interface = interface
self.codec = codec
self.synced_lyrics_format = synced_lyrics_format
self.no_synced_lyrics = no_synced_lyrics
self.synced_lyrics_only = synced_lyrics_only
self.use_album_date = use_album_date
self.fetch_extra_tags = fetch_extra_tags
async def get_download_item(
self,
song_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
download_item = DownloadItem()
download_item.media_metadata = song_metadata
download_item.playlist_metadata = playlist_metadata
song_id = self.interface.get_media_id_of_library_media(song_metadata)
download_item.lyrics = await self.interface.get_lyrics(
song_metadata,
self.synced_lyrics_format,
)
webplayback = await self.interface.apple_music_api.get_webplayback(song_id)
download_item.media_tags = await self.interface.get_tags(
webplayback,
download_item.lyrics.unsynced if download_item.lyrics else None,
self.use_album_date,
)
if self.fetch_extra_tags:
download_item.extra_tags = await self.interface.get_extra_tags(
song_metadata,
)
if playlist_metadata:
download_item.playlist_tags = self.get_playlist_tags(
playlist_metadata,
song_metadata,
)
download_item.playlist_file_path = self.get_playlist_file_path(
download_item.playlist_tags,
)
download_item.final_path = self.get_final_path(
download_item.media_tags,
".m4a",
download_item.playlist_tags,
)
download_item.synced_lyrics_path = self.get_lyrics_synced_path(
download_item.final_path,
)
if self.synced_lyrics_only:
return download_item
if self.codec.is_legacy():
download_item.stream_info = await self.interface.get_stream_info_legacy(
webplayback,
self.codec,
)
download_item.decryption_key = (
await self.interface.get_decryption_key_legacy(
download_item.stream_info,
self.cdm,
)
)
else:
download_item.stream_info = await self.interface.get_stream_info(
song_metadata,
self.codec,
)
if (
not self.use_wrapper
and download_item.stream_info
and download_item.stream_info.audio_track.widevine_pssh
):
download_item.decryption_key = await self.interface.get_decryption_key(
download_item.stream_info,
self.cdm,
)
else:
download_item.decryption_key = None
download_item.cover_url_template = self.interface.get_cover_url_template(
song_metadata,
self.cover_format,
)
download_item.cover_url = self.interface.get_cover_url(
download_item.cover_url_template,
self.cover_size,
self.cover_format,
)
download_item.random_uuid = self.get_random_uuid()
if download_item.stream_info and download_item.stream_info.file_format:
download_item.staged_path = self.get_temp_path(
song_id,
download_item.random_uuid,
"staged",
"." + download_item.stream_info.file_format.value,
)
else:
download_item.staged_path = None
cover_file_extension = await self.interface.get_cover_file_extension(
download_item.cover_url,
self.cover_format,
)
if cover_file_extension:
download_item.cover_path = self.get_cover_path(
download_item.final_path,
cover_file_extension,
)
return download_item
def fix_key_id(self, input_path: str):
count = 0
with open(input_path, "rb+") as file:
while data := file.read(4096):
pos = file.tell()
i = 0
while tenc := max(0, data.find(b"tenc", i)):
kid = tenc + 12
file.seek(max(0, pos - 4096) + kid, 0)
file.write(bytes.fromhex(f"{count:032}"))
count += 1
i = kid + 1
file.seek(pos, 0)
async def remux_mp4box(self, input_path: str, output_path: str):
await async_subprocess(
self.full_mp4box_path,
"-quiet",
"-add",
input_path,
"-itags",
"artist=placeholder",
"-keep-utc",
"-new",
output_path,
silent=self.silent,
)
async def remux_ffmpeg(
self,
input_path: str,
output_path: str,
decryption_key: str = None,
):
if decryption_key:
key = [
"-decryption_key",
decryption_key,
]
else:
key = []
await async_subprocess(
self.full_ffmpeg_path,
"-loglevel",
"error",
"-y",
*key,
"-i",
input_path,
"-c",
"copy",
"-movflags",
"+faststart",
output_path,
silent=self.silent,
)
async def decrypt_mp4decrypt(
self,
input_path: str,
output_path: str,
decryption_key: str,
legacy: bool,
):
if legacy:
keys = [
"--key",
f"1:{decryption_key}",
]
else:
self.fix_key_id(input_path)
keys = [
"--key",
"0" * 31 + "1" + f":{decryption_key}",
"--key",
"0" * 32 + f":{DEFAULT_SONG_DECRYPTION_KEY}",
]
await async_subprocess(
self.full_mp4decrypt_path,
*keys,
input_path,
output_path,
silent=self.silent,
)
async def decrypt_amdecrypt(
self,
input_path: str,
output_path: str,
media_id: str,
fairplay_key: str,
) -> None:
await async_subprocess(
self.amdecrypt_path,
self.wrapper_decrypt_ip,
self.full_mp4decrypt_path,
media_id,
fairplay_key,
input_path,
output_path,
)
async def stage(
self,
encrypted_path: str,
decrypted_path: str,
staged_path: str,
decryption_key: DecryptionKeyAv,
codec: SongCodec,
media_id: str,
fairplay_key: str,
):
if codec.is_legacy() and self.remux_mode == RemuxMode.FFMPEG:
await self.remux_ffmpeg(
encrypted_path,
staged_path,
decryption_key.audio_track.key,
)
elif codec.is_legacy() or not self.use_wrapper:
await self.decrypt_mp4decrypt(
encrypted_path,
decrypted_path,
decryption_key.audio_track.key,
codec.is_legacy(),
)
if self.remux_mode == RemuxMode.FFMPEG:
await self.remux_ffmpeg(
decrypted_path,
staged_path,
)
else:
await self.remux_mp4box(
decrypted_path,
staged_path,
)
else:
await self.decrypt_amdecrypt(
encrypted_path,
staged_path,
media_id,
fairplay_key,
)
def get_lyrics_synced_path(self, final_path: str) -> str:
return str(Path(final_path).with_suffix("." + self.synced_lyrics_format.value))
def get_cover_path(
self,
final_path: str,
file_extension: str,
) -> str:
return str(Path(final_path).parent / ("Cover" + file_extension))
def write_synced_lyrics(
self,
synced_lyrics: str,
lyrics_synced_path: str,
):
Path(lyrics_synced_path).parent.mkdir(parents=True, exist_ok=True)
Path(lyrics_synced_path).write_text(synced_lyrics, encoding="utf8")
async def download(
self,
download_item: DownloadItem,
) -> None:
if self.synced_lyrics_only:
return
encrypted_path = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"encrypted",
".m4a",
)
await self.download_stream(
download_item.stream_info.audio_track.stream_url,
encrypted_path,
)
decrypted_path = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"decrypted",
".m4a",
)
await self.stage(
encrypted_path,
decrypted_path,
download_item.staged_path,
download_item.decryption_key,
self.codec,
download_item.media_metadata["id"],
download_item.stream_info.audio_track.fairplay_key,
)
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
cover_bytes,
download_item.extra_tags,
)
@@ -1,103 +0,0 @@
from pathlib import Path
from ..interface.enums import UploadedVideoQuality
from ..interface.interface_uploaded_video import AppleMusicUploadedVideoInterface
from .downloader_base import AppleMusicBaseDownloader
from .types import DownloadItem
class AppleMusicUploadedVideoDownloader(AppleMusicBaseDownloader):
def __init__(
self,
base_downloader: AppleMusicBaseDownloader,
interface: AppleMusicUploadedVideoInterface,
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
):
self.__dict__.update(base_downloader.__dict__)
self.interface = interface
self.quality = quality
def get_cover_path(self, final_path: str, file_extension: str) -> str:
return str(Path(final_path).with_suffix(file_extension))
async def get_download_item(
self,
uploaded_video_metadata: dict,
) -> DownloadItem:
try:
return await self._get_download_item(
uploaded_video_metadata,
)
except Exception as e:
return DownloadItem(
media_metadata=uploaded_video_metadata,
error=e,
)
async def _get_download_item(
self,
uploaded_video_metadata: dict,
) -> DownloadItem:
download_item = DownloadItem()
download_item.media_metadata = uploaded_video_metadata
download_item.media_tags = self.interface.get_tags(
uploaded_video_metadata,
)
download_item.stream_info = await self.interface.get_stream_info(
uploaded_video_metadata,
self.quality,
)
download_item.random_uuid = self.get_random_uuid()
download_item.staged_path = self.get_temp_path(
uploaded_video_metadata["id"],
download_item.random_uuid,
"staged",
"." + download_item.stream_info.file_format.value,
)
download_item.final_path = self.get_final_path(
download_item.media_tags,
Path(download_item.staged_path).suffix,
None,
)
download_item.cover_url_template = self.interface.get_cover_url_template(
uploaded_video_metadata,
self.cover_format,
)
download_item.cover_url = self.interface.get_cover_url(
download_item.cover_url_template,
self.cover_size,
self.cover_format,
)
cover_file_extension = await self.interface.get_cover_file_extension(
download_item.cover_url,
self.cover_format,
)
if cover_file_extension:
download_item.cover_path = self.get_cover_path(
download_item.final_path,
cover_file_extension,
)
return download_item
async def download(
self,
download_item: DownloadItem,
) -> None:
await self.download_ytdlp(
download_item.stream_info.video_track.stream_url,
download_item.staged_path,
)
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
cover_bytes,
)
+13 -25
View File
@@ -1,32 +1,20 @@
class GamdlError(Exception):
from ..utils import GamdlError
class GamdlDownloaderError(GamdlError):
pass
class MediaFileExists(GamdlError):
def __init__(self, media_path: str):
super().__init__(f"Media file already exists at path: {media_path}")
class GamdlDownloaderSyncedLyricsOnlyError(GamdlDownloaderError):
def __init__(self) -> None:
super().__init__("Download mode is set to synced lyrics only")
class NotStreamable(GamdlError):
def __init__(self, media_id: str):
super().__init__(f"Media ID is not streamable: {media_id}")
class GamdlDownloaderMediaFileExistsError(GamdlDownloaderError):
def __init__(self, file_path: str) -> None:
super().__init__(f"Media file already exists: {file_path}")
class FormatNotAvailable(GamdlError):
def __init__(self, media_id: str):
super().__init__(f"Requested format is not available for media ID: {media_id}")
class ExecutableNotFound(GamdlError):
def __init__(self, executable: str):
super().__init__(f"Executable not found: {executable}")
class SyncedLyricsOnly(GamdlError):
def __init__(self):
super().__init__("Only downloading synced lyrics is supported")
class UnsupportedMediaType(GamdlError):
def __init__(self, media_type: str):
super().__init__(f"Unsupported media type: {media_type}")
class GamdlDownloaderDependencyNotFoundError(GamdlDownloaderError):
def __init__(self, dependency_name: str) -> None:
super().__init__(f"Required dependency not found: {dependency_name}")
-3
View File
@@ -1,3 +0,0 @@
# Dumped from Android Studio Virtual Device running Android 9
HARDCODED_WVD = """V1ZEAgIDAASoMIIEpAIBAAKCAQEAwnCFAPXy4U1J7p1NohAS+xl040f5FBaE/59bPp301bGz0UGFT9VoEtY3vaeakKh/d319xTNvCSWsEDRaMmp/wSnMiEZUkkl04872jx2uHuR4k6KYuuJoqhsIo1TwUBueFZynHBUJzXQeW8Eb1tYAROGwp8W7r+b0RIjHC89RFnfVXpYlF5I6McktyzJNSOwlQbMqlVihfSUkv3WRd3HFmA0Oxay51CEIkoTlNTHVlzVyhov5eHCDSp7QENRgaaQ03jC/CcgFOoQymhsBtRCM0CQmfuAHjA9e77R6m/GJPy75G9fqoZM1RMzVDHKbKZPd3sFd0c0+77gLzW8cWEaaHwIDAQABAoIBAQCB2pN46MikHvHZIcTPDt0eRQoDH/YArGl2Lf7J+sOgU2U7wv49KtCug9IGHwDiyyUVsAFmycrF2RroV45FTUq0vi2SdSXV7Kjb20Ren/vBNeQw9M37QWmU8Sj7q6YyWb9hv5T69DHvvDTqIjVtbM4RMojAAxYti5hmjNIh2PrWfVYWhXxCQ/WqAjWLtZBM6Oww1byfr5I/wFogAKkgHi8wYXZ4LnIC8V7jLAhujlToOvMMC9qwcBiPKDP2FO+CPSXaqVhH+LPSEgLggnU3EirihgxovbLNAuDEeEbRTyR70B0lW19tLHixso4ZQa7KxlVUwOmrHSZf7nVuWqPpxd+BAoGBAPQLyJ1IeRavmaU8XXxfMdYDoc8+xB7v2WaxkGXb6ToX1IWPkbMz4yyVGdB5PciIP3rLZ6s1+ruuRRV0IZ98i1OuN5TSR56ShCGg3zkd5C4L/xSMAz+NDfYSDBdO8BVvBsw21KqSRUi1ctL7QiIvfedrtGb5XrE4zhH0gjXlU5qZAoGBAMv2segn0Jx6az4rqRa2Y7zRx4iZ77JUqYDBI8WMnFeR54uiioTQ+rOs3zK2fGIWlrn4ohco/STHQSUTB8oCOFLMx1BkOqiR+UyebO28DJY7+V9ZmxB2Guyi7W8VScJcIdpSOPyJFOWZQKXdQFW3YICD2/toUx/pDAJh1sEVQsV3AoGBANyyp1rthmvoo5cVbymhYQ08vaERDwU3PLCtFXu4E0Ow90VNn6Ki4ueXcv/gFOp7pISk2/yuVTBTGjCblCiJ1en4HFWekJwrvgg3Vodtq8Okn6pyMCHRqvWEPqD5hw6rGEensk0K+FMXnF6GULlfn4mgEkYpb+PvDhSYvQSGfkPJAoGAF/bAKFqlM/1eJEvU7go35bNwEiij9Pvlfm8y2L8Qj2lhHxLV240CJ6IkBz1Rl+S3iNohkT8LnwqaKNT3kVB5daEBufxMuAmOlOX4PmZdxDj/r6hDg8ecmjj6VJbXt7JDd/c5ItKoVeGPqu035dpJyE+1xPAY9CLZel4scTsiQTkCgYBt3buRcZMwnc4qqpOOQcXK+DWD6QvpkcJ55ygHYw97iP/lF4euwdHd+I5b+11pJBAao7G0fHX3eSjqOmzReSKboSe5L8ZLB2cAI8AsKTBfKHWmCa8kDtgQuI86fUfirCGdhdA9AVP2QXN2eNCuPnFWi0WHm4fYuUB5be2c18ucxAb9CAESmgsK3QMIAhIQ071yBlsbLoO2CSB9Ds0cmRif6uevBiKOAjCCAQoCggEBAMJwhQD18uFNSe6dTaIQEvsZdONH+RQWhP+fWz6d9NWxs9FBhU/VaBLWN72nmpCof3d9fcUzbwklrBA0WjJqf8EpzIhGVJJJdOPO9o8drh7keJOimLriaKobCKNU8FAbnhWcpxwVCc10HlvBG9bWAEThsKfFu6/m9ESIxwvPURZ31V6WJReSOjHJLcsyTUjsJUGzKpVYoX0lJL91kXdxxZgNDsWsudQhCJKE5TUx1Zc1coaL+Xhwg0qe0BDUYGmkNN4wvwnIBTqEMpobAbUQjNAkJn7gB4wPXu+0epvxiT8u+RvX6qGTNUTM1QxymymT3d7BXdHNPu+4C81vHFhGmh8CAwEAASjwIkgBUqoBCAEQABqBAQQlRbfiBNDb6eU6aKrsH5WJaYszTioXjPLrWN9dqyW0vwfT11kgF0BbCGkAXew2tLJJqIuD95cjJvyGUSN6VyhL6dp44fWEGDSBIPR0mvRq7bMP+m7Y/RLKf83+OyVJu/BpxivQGC5YDL9f1/A8eLhTDNKXs4Ia5DrmTWdPTPBL8SIgyfUtg3ofI+/I9Tf7it7xXpT0AbQBJfNkcNXGpO3JcBMSgAIL5xsXK5of1mMwAl6ygN1Gsj4aZ052otnwN7kXk12SMsXheWTZ/PYh2KRzmt9RPS1T8hyFx/Kp5VkBV2vTAqqWrGw/dh4URqiHATZJUlhO7PN5m2Kq1LVFdXjWSzP5XBF2S83UMe+YruNHpE5GQrSyZcBqHO0QrdPcU35GBT7S7+IJr2AAXvnjqnb8yrtpPWN2ZW/IWUJN2z4vZ7/HV4aj3OZhkxC1DIMNyvsusUKoQQuf8gwKiEe8cFwbwFSicywlFk9la2IPe8oFShcxAzHLCCn/TIYUAvEL3/4LgaZvqWm80qCPYbgIP5HT8hPYkKWJ4WYknEWK+3InbnkzteFfGrQFCq4CCAESEGnj6Ji7LD+4o7MoHYT4jBQYjtW+kQUijgIwggEKAoIBAQDY9um1ifBRIOmkPtDZTqH+CZUBbb0eK0Cn3NHFf8MFUDzPEz+emK/OTub/hNxCJCao//pP5L8tRNUPFDrrvCBMo7Rn+iUb+mA/2yXiJ6ivqcN9Cu9i5qOU1ygon9SWZRsujFFB8nxVreY5Lzeq0283zn1Cg1stcX4tOHT7utPzFG/ReDFQt0O/GLlzVwB0d1sn3SKMO4XLjhZdncrtF9jljpg7xjMIlnWJUqxDo7TQkTytJmUl0kcM7bndBLerAdJFGaXc6oSY4eNy/IGDluLCQR3KZEQsy/mLeV1ggQ44MFr7XOM+rd+4/314q/deQbjHqjWFuVr8iIaKbq+R63ShAgMBAAEo8CISgAMii2Mw6z+Qs1bvvxGStie9tpcgoO2uAt5Zvv0CDXvrFlwnSbo+qR71Ru2IlZWVSbN5XYSIDwcwBzHjY8rNr3fgsXtSJty425djNQtF5+J2jrAhf3Q2m7EI5aohZGpD2E0cr+dVj9o8x0uJR2NWR8FVoVQSXZpad3M/4QzBLNto/tz+UKyZwa7Sc/eTQc2+ZcDS3ZEO3lGRsH864Kf/cEGvJRBBqcpJXKfG+ItqEW1AAPptjuggzmZEzRq5xTGf6or+bXrKjCpBS9G1SOyvCNF1k5z6lG8KsXhgQxL6ADHMoulxvUIihyPY5MpimdXfUdEQ5HA2EqNiNVNIO4qP007jW51yAeThOry4J22xs8RdkIClOGAauLIl0lLA4flMzW+VfQl5xYxP0E5tuhn0h+844DslU8ZF7U1dU2QprIApffXD9wgAACk26Rggy8e96z8i86/+YYyZQkc9hIdCAERrgEYCEbByzONrdRDs1MrS/ch1moV5pJv63BIKvQHGvLkaFwoMY29tcGFueV9uYW1lEgd1bmtub3duGioKCm1vZGVsX25hbWUSHEFuZHJvaWQgU0RLIGJ1aWx0IGZvciB4ODZfNjQaGwoRYXJjaGl0ZWN0dXJlX25hbWUSBng4Nl82NBodCgtkZXZpY2VfbmFtZRIOZ2VuZXJpY194ODZfNjQaIAoMcHJvZHVjdF9uYW1lEhBzZGtfcGhvbmVfeDg2XzY0GmMKCmJ1aWxkX2luZm8SVUFuZHJvaWQvc2RrX3Bob25lX3g4Nl82NC9nZW5lcmljX3g4Nl82NDo5L1BTUjEuMTgwNzIwLjAxMi80OTIzMjE0OnVzZXJkZWJ1Zy90ZXN0LWtleXMaHgoUd2lkZXZpbmVfY2RtX3ZlcnNpb24SBjE0LjAuMBokCh9vZW1fY3J5cHRvX3NlY3VyaXR5X3BhdGNoX2xldmVsEgEwMg4QASAAKA0wAEAASABQAA=="""
+213
View File
@@ -0,0 +1,213 @@
from pathlib import Path
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
from ..utils import async_subprocess
from .base import AppleMusicBaseDownloader
from .enums import RemuxFormatMusicVideo, RemuxMode
from .types import DownloadItem
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,
):
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,
decryption_key.audio_track.key,
)
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,
final_path: str,
file_extension: str,
) -> str:
return str(Path(final_path).with_suffix(file_extension))
async def get_download_item(
self,
media: AppleMusicMedia,
) -> DownloadItem:
download_item = DownloadItem(media)
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,
"." + media.stream_info.file_format.value,
media.playlist_tags,
)
if media.playlist_tags:
download_item.playlist_file_path = self.base.get_playlist_file_path(
media.playlist_tags,
)
download_item.cover_path = self.get_cover_path(
download_item.final_path,
media.cover.file_extension,
)
return download_item
async def download(
self,
download_item: DownloadItem,
) -> None:
encrypted_path_video = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"encrypted_video",
".mp4",
)
encrypted_path_audio = self.base.get_temp_path(
download_item.media.media_metadata["id"],
download_item.uuid_,
"encrypted_audio",
".m4a",
)
await self.base.download_stream(
download_item.media.stream_info.video_track.stream_url,
encrypted_path_video,
)
await self.base.download_stream(
download_item.media.stream_info.audio_track.stream_url,
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,
)
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,
)
+181
View File
@@ -0,0 +1,181 @@
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.interface.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,
)
+4 -33
View File
@@ -1,44 +1,15 @@
import uuid
from dataclasses import dataclass
from typing import Any
from ..interface.types import (
DecryptionKeyAv,
Lyrics,
MediaTags,
PlaylistTags,
StreamInfoAv,
)
from ..interface.types import AppleMusicMedia
@dataclass
class DownloadItem:
media_metadata: dict = None
playlist_metadata: dict = None
random_uuid: str = None
lyrics: Lyrics = None
media_tags: MediaTags = None
extra_tags: dict = None
playlist_tags: PlaylistTags = None
stream_info: StreamInfoAv = None
decryption_key: DecryptionKeyAv = None
cover_url_template: str = None
cover_url: str = None
media: AppleMusicMedia
uuid_: str = uuid.uuid4().hex[:8]
staged_path: str = None
final_path: str = None
playlist_file_path: str = None
synced_lyrics_path: str = None
cover_path: str = None
flat_filter_result: Any = None
error: Exception = None
@dataclass
class UrlInfo:
storefront: str = None
type: str = None
slug: str = None
id: str = None
sub_id: str = None
library_storefront: str = None
library_type: str = None
library_id: str = None
+65
View File
@@ -0,0 +1,65 @@
from pathlib import Path
from ..interface.enums import CoverFormat
from ..interface.types import AppleMusicMedia
from .base import AppleMusicBaseDownloader
from .types import DownloadItem
class AppleMusicUploadedVideoDownloader:
def __init__(
self,
base: AppleMusicBaseDownloader,
):
self.base = base
def get_cover_path(self, final_path: str, file_extension: str) -> str:
return str(Path(final_path).with_suffix(file_extension))
async def get_download_item(
self,
media: AppleMusicMedia,
) -> DownloadItem:
download_item = DownloadItem(media)
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,
"." + media.stream_info.file_format.value,
media.playlist_tags,
)
download_item.cover_path = self.get_cover_path(
download_item.final_path,
media.cover.file_extension,
)
return download_item
async def download(
self,
download_item: DownloadItem,
) -> None:
await self.base._download_ytdlp_async(
download_item.media.stream_info.video_track.stream_url,
download_item.staged_path,
)
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,
)
+6 -4
View File
@@ -1,6 +1,8 @@
from .base import AppleMusicBaseInterface
from .enums import *
from .interface import *
from .interface_music_video import *
from .interface_song import *
from .interface_uploaded_video import *
from .exceptions import *
from .interface import AppleMusicInterface
from .music_video import AppleMusicMusicVideoInterface
from .song import AppleMusicSongInterface
from .types import *
from .uploaded_video import AppleMusicUploadedVideoInterface
+329
View File
@@ -0,0 +1,329 @@
import asyncio
import base64
import datetime
import re
from io import BytesIO
import httpx
import structlog
from async_lru import alru_cache
from PIL import Image
from pywidevine import PSSH, Cdm, Device
from pywidevine.license_protocol_pb2 import WidevinePsshData
from gamdl.interface.wvd import WVD
from ..api.apple_music import AppleMusicApi
from ..api.itunes import ItunesApi
from .constants import IMAGE_FILE_EXTENSION_MAP
from .enums import CoverFormat
from .types import Cover, DecryptionKey, PlaylistTags
logger = structlog.get_logger(__name__)
class AppleMusicBaseInterface:
def __init__(
self,
apple_music_api: AppleMusicApi,
itunes_api: ItunesApi,
cover_format: CoverFormat,
cover_size: int,
use_wrapper: bool,
wrapper_m3u8_ip: str,
cdm: Cdm,
) -> None:
self.apple_music_api = apple_music_api
self.itunes_api = itunes_api
self.cover_format = cover_format
self.cover_size = cover_size
self.use_wrapper = use_wrapper
self.wrapper_m3u8_ip = wrapper_m3u8_ip
self.cdm = cdm
@staticmethod
def create_cdm(wvd_path: str | None = None) -> Cdm:
if wvd_path:
cdm = Cdm.from_device(Device.load(wvd_path))
else:
cdm = Cdm.from_device(Device.loads(WVD))
cdm.MAX_NUM_OF_SESSIONS = float("inf")
return cdm
@staticmethod
def is_media_streamable(
media_metadata: dict,
) -> 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")
if media_url is None:
return None
url_media_id = media_url.split("/")[-1].split("?")[0]
return url_media_id
@staticmethod
def parse_date(date: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(date.split("Z")[0])
@staticmethod
def reconstruct_pssh(pssh: str) -> bytes:
pssh = pssh.split(",")[-1]
decoded_pssh = base64.b64decode(pssh)
if len(decoded_pssh) > 30:
return pssh
widevine_pssh_data = WidevinePsshData(
algorithm=1,
key_ids=[decoded_pssh],
)
return widevine_pssh_data.SerializeToString()
@staticmethod
async def get_response(
url: str,
valid_responses: list[int] = [200],
) -> httpx.Response:
async with httpx.AsyncClient(timeout=60.0) as client:
try:
response = await client.get(url)
response.raise_for_status()
except httpx.HTTPStatusError as e:
if e.response.status_code in valid_responses:
return e.response
raise e
return response
@staticmethod
def format_cover(
template_cover_url: str,
cover_size: int,
cover_format: CoverFormat,
) -> str:
return re.sub(
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
f"/{cover_size}x{cover_size}bb.{cover_format.value}",
template_cover_url,
)
@classmethod
async def create(
cls,
apple_music_api: AppleMusicApi,
cover_format: CoverFormat = CoverFormat.JPG,
cover_size: int = 1200,
use_wrapper: bool = False,
wrapper_m3u8_ip: str = "127.0.0.1:20020",
wvd_path: str | None = None,
itunes_api: ItunesApi | None = None,
):
itunes_api = itunes_api or await ItunesApi.create(
storefront=apple_music_api.storefront,
language=apple_music_api.language,
)
cdm = cls.create_cdm(wvd_path)
base = cls(
apple_music_api=apple_music_api,
itunes_api=itunes_api,
cover_format=cover_format,
cover_size=cover_size,
use_wrapper=use_wrapper,
wrapper_m3u8_ip=wrapper_m3u8_ip,
cdm=cdm,
)
return base
@alru_cache()
async def get_album_cached(
self,
album_id: int,
) -> dict | None:
return (await self.apple_music_api.get_album(album_id))["data"][0]
async def get_decryption_key(
self,
pssh: str,
track_id: str,
) -> DecryptionKey:
log = logger.bind(action="get_decryption_key", track_id=track_id)
reconstructed_pssh = self.reconstruct_pssh(pssh)
cdm_session = self.cdm.open()
try:
pssh_obj = PSSH(reconstructed_pssh)
challenge = base64.b64encode(
await asyncio.to_thread(
self.cdm.get_license_challenge, cdm_session, pssh_obj
)
).decode()
license = await self.apple_music_api.get_license_exchange(
track_id,
pssh,
challenge,
)
await asyncio.to_thread(
self.cdm.parse_license, cdm_session, license["license"]
)
decryption_key_info = next(
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
)
finally:
self.cdm.close(cdm_session)
decryption_key = DecryptionKey(
key=decryption_key_info.key.hex(),
kid=decryption_key_info.kid.hex,
)
log.debug("success", decryption_key=decryption_key)
return decryption_key
@alru_cache()
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)
if response.status_code == 404:
log.debug("cover_not_found")
return None
response.raise_for_status()
return response.content
def _get_cover_template_url(self, metadata: dict) -> str:
if self.cover_format == CoverFormat.RAW:
cover_template_url = self._get_raw_cover_url(
metadata["attributes"]["artwork"]["url"]
)
else:
cover_template_url = metadata["attributes"]["artwork"]["url"]
return cover_template_url
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
cover_url_template,
),
)
@alru_cache()
async def _get_cover_file_extension(
self,
cover_url: str,
) -> str | None:
log = logger.bind(action="get_cover_file_extension", cover_url=cover_url)
if self.cover_format != CoverFormat.RAW:
return f".{self.cover_format.value}"
cover_bytes = await self.get_cover_bytes(cover_url)
if cover_bytes is None:
log.debug("cover_bytes_empty")
return None
image_obj = Image.open(BytesIO(cover_bytes))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(
image_format,
f".{image_format.lower()}",
)
async def get_cover(
self,
metadata: dict,
) -> str:
log = logger.bind(
action="get_cover", media_id=self.parse_catalog_media_id(metadata)
)
template_url = self._get_cover_template_url(metadata)
if self.cover_format == CoverFormat.RAW:
cover_url = template_url
else:
cover_url = self.format_cover(
template_url,
self.cover_size,
self.cover_format,
)
cover_file_extension = await self._get_cover_file_extension(cover_url)
cover = Cover(
template_url=template_url,
url=cover_url,
file_extension=cover_file_extension,
)
log.debug("success", cover=cover)
return cover
@alru_cache()
async def get_media_date(
self,
media_id: str,
) -> datetime.datetime | None:
log = logger.bind(action="get_media_date", media_id=media_id)
lookup_result = await self.itunes_api.get_lookup_result(media_id)
if not lookup_result["results"]:
log.debug("no_media_id")
return None
release_date = lookup_result["results"][0].get("releaseDate")
if not release_date:
log.debug("no_release_date")
return None
parsed_date = self.parse_date(release_date)
log.debug("success", release_date=parsed_date)
return parsed_date
def get_playlist_tags(
self,
playlist_metadata: dict,
playlist_track: int,
) -> PlaylistTags:
log = logger.bind(
action="get_playlist_tags",
playlist_id=playlist_metadata["id"],
)
playlist_tags = PlaylistTags(
artist=playlist_metadata["attributes"].get("curatorName", "Unknown"),
playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
title=playlist_metadata["attributes"]["name"],
track=playlist_track,
)
log.debug("success", playlist_tags=playlist_tags)
return playlist_tags
+36
View File
@@ -1,3 +1,5 @@
import re
MEDIA_TYPE_STR_MAP = {
1: "Song",
6: "Music Video",
@@ -60,3 +62,37 @@ IMAGE_FILE_EXTENSION_MAP = {
"jpeg": ".jpg",
"tiff": ".tif",
}
VALID_URL_PATTERN = re.compile(
r"https://(?:classical\.)?music\.apple\.com"
r"(?:"
r"/(?P<storefront>[a-z]{2})"
r"/(?P<type>artist|album|playlist|song|music-video|post)"
r"(?:/(?P<slug>[^\s/]+))?"
r"/(?P<id>[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]+)"
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")"
)
ARTIST_AUTO_SELECT_KEY_MAP = {
"main-albums": ("views", "full-albums"),
"compilation-albums": ("views", "compilation-albums"),
"live-albums": ("views", "live-albums"),
"singles-eps": ("views", "singles"),
"all-albums": ("relationships", "albums"),
"top-songs": ("views", "top-songs"),
"music-videos": ("relationships", "music-videos"),
}
ARTIST_AUTO_SELECT_STR_MAP = {
"main-albums": "Main Albums",
"compilation-albums": "Compilation Albums",
"live-albums": "Live Albums",
"singles-eps": "Singles & EPs",
"all-albums": "All Albums",
"top-songs": "Top Songs",
"music-videos": "Music Videos",
}
+19
View File
@@ -1,6 +1,8 @@
from enum import Enum
from .constants import (
ARTIST_AUTO_SELECT_KEY_MAP,
ARTIST_AUTO_SELECT_STR_MAP,
FOURCC_MAP,
LEGACY_SONG_CODECS,
MEDIA_RATING_STR_MAP,
@@ -93,3 +95,20 @@ class CoverFormat(Enum):
JPG = "jpg"
PNG = "png"
RAW = "raw"
class ArtistMediaType(Enum):
MAIN_ALBUMS = "main-albums"
COMPILATION_ALBUMS = "compilation-albums"
LIVE_ALBUMS = "live-albums"
SINGLES_EPS = "singles-eps"
ALL_ALBUMS = "all-albums"
TOP_SONGS = "top-songs"
MUSIC_VIDEOS = "music-videos"
@property
def path_key(self) -> tuple[str, str]:
return ARTIST_AUTO_SELECT_KEY_MAP[self.value]
def __str__(self) -> str:
return ARTIST_AUTO_SELECT_STR_MAP[self.value]
+51
View File
@@ -0,0 +1,51 @@
from ..utils import GamdlError
from typing import Any
class GamdlInterfaceError(GamdlError):
pass
class GamdlInterfaceMediaNotStreamableError(GamdlInterfaceError):
def __init__(self, media_id: str):
super().__init__(f"Media is not streamable: {media_id}")
class GamdlInterfaceFormatNotAvailableError(GamdlInterfaceError):
def __init__(self, media_id: str, codec: Any | None = None):
super().__init__(
f"Requested format is not available (media ID: {media_id}): {codec}"
)
class GamdlInterfaceDecryptionNotAvailableError(GamdlInterfaceError):
def __init__(self, media_id: str):
super().__init__(f"Decryption is not available for media ID: {media_id}")
class GamdlInterfaceMediaNotAllowedError(GamdlInterfaceError):
def __init__(self, media_type: str, media_id: str | None = None):
message = "Media type is disallowed"
if media_id:
message += f" (media ID: {media_id})"
super().__init__(f"{message}: {media_type}")
class GamdlInterfaceUrlParseError(GamdlInterfaceError):
def __init__(self, url: str):
super().__init__(f"URL is not valid or supported: {url}")
class GamdlInterfaceArtistMediaTypeError(GamdlInterfaceError):
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}")
self.result = result
+446 -137
View File
@@ -1,161 +1,470 @@
import asyncio
import base64
import datetime
import logging
import re
from io import BytesIO
from typing import Any, AsyncGenerator, Callable
from async_lru import alru_cache
from PIL import Image
from pywidevine import PSSH, Cdm
import structlog
from ..api.apple_music_api import AppleMusicApi
from ..api.itunes_api import ItunesApi
from ..utils import get_response
from .constants import IMAGE_FILE_EXTENSION_MAP
from .enums import CoverFormat
from .types import DecryptionKey
from ..utils import safe_gather
from .constants import VALID_URL_PATTERN
from .enums import ArtistMediaType
from .exceptions import (
GamdlInterfaceMediaNotAllowedError,
GamdlInterfaceUrlParseError,
GamdlInterfaceArtistMediaTypeError,
GamdlInterfaceFlatFilterExcludedError,
)
from .music_video import AppleMusicMusicVideoInterface
from .song import AppleMusicSongInterface
from .types import AppleMusicMedia, AppleMusicUrlInfo
from .uploaded_video import AppleMusicUploadedVideoInterface
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class AppleMusicInterface:
def __init__(
self,
apple_music_api: AppleMusicApi,
itunes_api: ItunesApi,
song: AppleMusicSongInterface,
music_video: AppleMusicMusicVideoInterface,
uploaded_video: AppleMusicUploadedVideoInterface,
artist_select_media_type_function: (
Callable[[list[ArtistMediaType], dict], ArtistMediaType | None] | None
) = None,
artist_select_items_function: (
Callable[[ArtistMediaType, list[dict]], list[dict] | None] | None
) = None,
flat_filter_function: Callable[[dict], Any] | None = None,
concurrency: int = 1,
disallowed_media_types: list[str] | None = None,
) -> None:
self.apple_music_api = apple_music_api
self.itunes_api = itunes_api
self.song = song
self.music_video = music_video
self.uploaded_video = uploaded_video
self.artist_select_media_type_function = artist_select_media_type_function
self.artist_select_items_function = artist_select_items_function
self.flat_filter_function = flat_filter_function
self.concurrency = concurrency
self.disallowed_media_types = disallowed_media_types
self.base = song.base
@staticmethod
def get_media_id_of_library_media(library_media_metadata: dict) -> str:
play_params = library_media_metadata["attributes"].get("playParams", {})
return play_params.get("catalogId", library_media_metadata["id"])
def get_url_info(url: str) -> AppleMusicUrlInfo | None:
log = logger.bind(action="get_url_info", url=url)
@staticmethod
def parse_date(date: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(date.split("Z")[0])
match = VALID_URL_PATTERN.match(url)
if not match:
log.debug("invalid_url_pattern")
async def get_decryption_key(
self,
track_uri: str,
track_id: str,
cdm: Cdm,
) -> DecryptionKey:
try:
cdm_session = cdm.open()
pssh_obj = PSSH(track_uri.split(",")[-1])
challenge = base64.b64encode(
await asyncio.to_thread(
cdm.get_license_challenge, cdm_session, pssh_obj
)
).decode()
license = await self.apple_music_api.get_license_exchange(
track_id,
track_uri,
challenge,
)
await asyncio.to_thread(cdm.parse_license, cdm_session, license["license"])
decryption_key_info = next(
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
)
finally:
cdm.close(cdm_session)
decryption_key = DecryptionKey(
key=decryption_key_info.key.hex(),
kid=decryption_key_info.kid.hex,
)
logger.debug(f"Decryption key: {decryption_key}")
return decryption_key
def get_cover_url_template(self, metadata: dict, cover_format: CoverFormat) -> str:
if cover_format == CoverFormat.RAW:
cover_url_template = self._get_raw_cover_url(
metadata["attributes"]["artwork"]["url"]
)
cover_url_template = metadata["attributes"]["artwork"]["url"]
logger.debug(f"Cover URL template: {cover_url_template}")
return cover_url_template
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
cover_url_template,
),
)
def get_cover_url(
self,
cover_url_template: str,
cover_size: int,
cover_format: CoverFormat,
) -> str:
cover_url = re.sub(
r"\{w\}x\{h\}([a-z]{2})\.jpg",
(
f"{cover_size}x{cover_size}bb.{cover_format.value}"
if cover_format != CoverFormat.RAW
else ""
),
cover_url_template,
)
logger.debug(f"Cover URL: {cover_url}")
return cover_url
@alru_cache()
async def get_cover_file_extension(
self,
cover_url: str,
cover_format: CoverFormat,
) -> str | None:
if cover_format != CoverFormat.RAW:
return f".{cover_format.value}"
cover_url = self.get_cover_url(cover_url)
cover_bytes = await self.get_cover_bytes(cover_url)
if cover_bytes is None:
return None
image_obj = Image.open(BytesIO(self.get_cover_bytes(cover_url)))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(
image_format,
f".{image_format.lower()}",
url_match = AppleMusicUrlInfo(
**match.groupdict(),
)
@alru_cache()
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
response = await get_response(cover_url, {200, 404})
if response.status_code == 200:
return response.content
return None
log.debug("success", url_info=url_match)
@alru_cache()
async def get_media_date(
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,
) -> datetime.datetime | None:
lookup_result = await self.itunes_api.get_lookup_result(media_id)
if not lookup_result["results"]:
return None
index: int | None = None,
total: int | None = None,
media_metadata: dict | None = None,
playlist_metadata: dict | None = None,
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
)
release_date = lookup_result["results"][0].get("releaseDate")
if not release_date:
return None
if index is not None:
media.index = index
if total is not None:
media.total = total
parsed_date = self.parse_date(release_date)
logger.debug(f"Parsed media date: {parsed_date}")
media.media_metadata = media_metadata
media.playlist_metadata = playlist_metadata
return parsed_date
try:
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:
media.partial = False
media.error = e
yield media
return
async def _get_music_video_media(
self,
media_id: str,
index: int | None = None,
total: int | None = None,
media_metadata: dict | None = None,
playlist_metadata: dict | None = None,
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
)
if index is not None:
media.index = index
if total is not None:
media.total = total
media.media_metadata = media_metadata
media.playlist_metadata = playlist_metadata
try:
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:
media.partial = False
media.error = e
yield media
return
async def _get_uploaded_video_media(
self,
media_id: str,
) -> AsyncGenerator[AppleMusicMedia, None]:
media = AppleMusicMedia(
media_id=media_id,
)
try:
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:
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:
base_media.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,
)
)["data"][0]
self._run_media_type_filter(base_media)
await self._run_flat_filter(base_media)
except Exception as e:
base_media.partial = False
base_media.error = e
yield base_media
return
yield base_media
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,
)
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,
)
)
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_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 await 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,
total=base_media.media_metadata["attributes"]["trackCount"],
media_metadata=track,
playlist_metadata=base_media.media_metadata,
)
if track["type"] in {"songs", "library-songs"}
else self._get_music_video_media(
media_id=track["id"],
index=index,
total=base_media.media_metadata["attributes"]["trackCount"],
media_metadata=track,
playlist_metadata=base_media.media_metadata,
)
)
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:
base_media.media_metadata = (
await self.base.apple_music_api.get_artist(
media_id,
)
)["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,
media_metadata=None,
error=e,
)
return
yield base_media
if self.artist_select_items_function:
selected_items = self.artist_select_items_function(
artist_media_type,
items,
)
if asyncio.iscoroutine(selected_items):
selected_items = await selected_items
else:
selected_items = items[:1]
tasks = []
for index, item in enumerate(selected_items):
if item["type"] in {"songs", "library-songs"}:
tasks.append(
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(
self._get_album_media(
media_id=item["id"],
)
)
else:
tasks.append(
self._get_music_video_media(
media_id=item["id"],
index=index,
total=len(selected_items),
media_metadata=item,
)
)
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_media_from_url(
self,
url: str,
) -> AsyncGenerator[AppleMusicMedia, None]:
url_info = self.get_url_info(url)
if not url_info:
raise GamdlInterfaceUrlParseError(url)
if self.disallowed_media_types and url_info.type in self.disallowed_media_types:
raise GamdlInterfaceMediaNotAllowedError(
url_info.type,
)
if url_info.type == "song" or url_info.sub_id:
async for media in self._get_song_media(
media_id=url_info.sub_id or url_info.id,
index=0,
total=1,
):
yield media
elif url_info.type == "music-video":
async for media in self._get_music_video_media(
media_id=url_info.id,
index=0,
total=1,
):
yield media
elif url_info.type == "album" or url_info.library_type == "albums":
async for media in self._get_album_media(
media_id=url_info.library_id or url_info.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,
is_library=bool(url_info.library_type),
):
yield media
elif url_info.type == "post":
async for media in self._get_uploaded_video_media(
media_id=url_info.id,
):
yield media
elif url_info.type == "artist":
async for media in self._get_artist_media(
media_id=url_info.id,
):
yield media
-380
View File
@@ -1,380 +0,0 @@
import logging
import urllib.parse
import m3u8
from async_lru import alru_cache
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from pywidevine import Cdm
from ..utils import get_response
from .constants import MP4_FORMAT_CODECS
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
from .interface import AppleMusicInterface
from .types import DecryptionKeyAv, MediaFileFormat, MediaTags, StreamInfo, StreamInfoAv
logger = logging.getLogger(__name__)
class AppleMusicMusicVideoInterface(AppleMusicInterface):
def __init__(self, interface: AppleMusicInterface):
self.__dict__.update(interface.__dict__)
async def get_itunes_page_metadata(
self,
music_video_metadata: dict,
) -> dict:
alt_id = self.get_alt_id(music_video_metadata)
itunes_page = await self.itunes_api.get_itunes_page(
"music-video",
alt_id,
)
return itunes_page["storePlatformData"]["product-dv"]["results"][alt_id]
def get_m3u8_master_url_from_webplayback(self, webplayback: dict) -> str:
m3u8_master_url = webplayback["hls-playlist-url"]
return m3u8_master_url
def get_m3u8_master_url_from_itunes_page_metadata(
self,
itunes_page_metadata: dict,
) -> dict:
stream_url = itunes_page_metadata["offers"][0]["assets"][0]["hlsUrl"]
url_parts = urllib.parse.urlparse(stream_url)
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
query.update({"aec": "HD", "dsid": "1"})
m3u8_master_url = url_parts._replace(
query=urllib.parse.urlencode(query, doseq=True)
).geturl()
return m3u8_master_url
def get_alt_id(self, metadata: dict) -> str | None:
music_video_url = metadata["attributes"].get("url")
if music_video_url is None:
return None
alt_id = music_video_url.split("/")[-1].split("?")[0]
logger.debug(f"Alt ID: {alt_id}")
return alt_id
@alru_cache()
async def get_album(
self,
collection_id: int,
) -> dict | None:
album_response = await self.apple_music_api.get_album(collection_id)
if not album_response:
return None
return album_response["data"][0]
async def get_tags(
self,
metadata: dict,
itunes_page_metadata: dict,
) -> MediaTags:
alt_id = self.get_alt_id(metadata)
lookup_metadata = (await self.itunes_api.get_lookup_result(alt_id))["results"]
explicitness = lookup_metadata[0]["trackExplicitness"]
if explicitness == "notExplicit":
rating = MediaRating.NONE
elif explicitness == "explicit":
rating = MediaRating.EXPLICIT
else:
rating = MediaRating.CLEAN
tags = MediaTags(
artist=lookup_metadata[0]["artistName"],
artist_id=int(lookup_metadata[0]["artistId"]),
copyright=itunes_page_metadata.get("copyright"),
date=self.parse_date(lookup_metadata[0]["releaseDate"]),
genre=lookup_metadata[0]["primaryGenreName"],
genre_id=int(itunes_page_metadata["genres"][0]["genreId"]),
media_type=MediaType.MUSIC_VIDEO,
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
title=lookup_metadata[0]["trackCensoredName"],
title_id=int(metadata["id"]),
rating=rating,
)
if len(lookup_metadata) > 1:
album = await self.get_album(itunes_page_metadata["collectionId"])
if not album:
return tags
tags.album = 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"]
tags.disc_total = lookup_metadata[0]["discCount"]
tags.compilation = album["attributes"]["isCompilation"]
tags.track = lookup_metadata[0]["trackNumber"]
tags.track_total = lookup_metadata[0]["trackCount"]
logger.debug(f"Tags: {tags}")
return tags
async def get_stream_info(
self,
metadata: dict,
itunes_page_metadata: dict,
codec_priority: list[MusicVideoCodec],
resolution: MusicVideoResolution,
) -> StreamInfoAv:
alt_video_id = self.get_alt_id(metadata)
if alt_video_id == metadata["id"]:
m3u8_master_url = self.get_m3u8_master_url_from_itunes_page_metadata(
itunes_page_metadata,
)
else:
webplayback_response = await self.apple_music_api.get_webplayback(
metadata["id"]
)
m3u8_master_url = self.get_m3u8_master_url_from_webplayback(
webplayback_response["songList"][0],
)
playlist_master_m3u8_obj = m3u8.loads(
(await 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,
codec_priority,
resolution,
)
stream_info_audio = await self.get_stream_info_audio(
playlist_master_m3u8_obj.data,
codec_priority,
)
if not stream_info_video or not stream_info_audio:
return None
use_mp4 = any(
stream_info_video.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
) or any(
stream_info_audio.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
)
if use_mp4:
file_format = MediaFileFormat.MP4
else:
file_format = MediaFileFormat.M4V
stream_info = StreamInfoAv(
video_track=stream_info_video,
audio_track=stream_info_audio,
file_format=file_format,
)
logger.debug(f"Stream info: {stream_info}")
return stream_info
def get_video_playlist_from_resolution(
self,
video_playlists: list[m3u8.Playlist],
codec_priority: list[MusicVideoCodec],
resolution: MusicVideoResolution,
) -> m3u8.Playlist | None:
playlist_results = []
for codec_index, codec in enumerate(codec_priority):
for playlist in video_playlists:
if playlist.stream_info.codecs.startswith(codec.fourcc()):
playlist_results.append((codec_index, playlist))
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_resolution = playlist.stream_info.resolution[-1]
bandwidth = playlist.stream_info.bandwidth
exceeds_resolution = playlist_resolution > int(resolution)
resolution_difference = abs(playlist_resolution - int(resolution))
return (
exceeds_resolution,
resolution_difference,
codec_index,
-playlist_resolution,
-bandwidth,
)
playlist_results.sort(key=sort_key)
return playlist_results[0][1]
def get_best_stereo_audio_playlist(
self,
playlist_master_data: dict,
) -> dict | None:
audio_playlist = next(
(
media
for media in playlist_master_data["media"]
if media["group_id"] == "audio-stereo-256"
),
None,
)
return audio_playlist
async def get_video_playlist_from_user(
self,
video_playlists: list[m3u8.Playlist],
) -> m3u8.Playlist:
choices = [
Choice(
name=" | ".join(
[
playlist.stream_info.codecs[:4],
"x".join(str(v) for v in playlist.stream_info.resolution),
str(playlist.stream_info.bandwidth),
]
),
value=playlist,
)
for playlist in video_playlists
]
selected = await inquirer.select(
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
choices=choices,
).execute_async()
return selected
async def get_audio_playlist_from_user(
self,
playlist_master_data: dict,
) -> dict:
choices = [
Choice(
name=playlist["group_id"],
value=playlist,
)
for playlist in playlist_master_data["media"]
if playlist.get("uri")
]
selected = await inquirer.select(
message="Select which audio codec to download:",
choices=choices,
).execute_async()
return selected
def _get_key_by_format(
self,
m3u8_obj: m3u8.M3U8,
key_format: str,
) -> str:
return next(
(key for key in m3u8_obj.keys if key.keyformat == key_format),
None,
).uri
def get_widevine_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
return self._get_key_by_format(
m3u8_obj,
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
)
def get_playready_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
return self._get_key_by_format(
m3u8_obj,
"com.microsoft.playready",
)
def get_fairplay_key(self, m3u8_obj: m3u8.M3U8) -> str:
return self._get_key_by_format(
m3u8_obj,
"com.apple.streamingkeydelivery",
)
async def get_stream_info_video(
self,
playlist_master_m3u8_obj: m3u8.M3U8,
codec_priority: list[MusicVideoCodec],
resolution: MusicVideoResolution,
) -> StreamInfo | None:
stream_info = StreamInfo()
if MusicVideoCodec.ASK not in codec_priority:
playlist = self.get_video_playlist_from_resolution(
playlist_master_m3u8_obj.playlists,
codec_priority,
resolution,
)
else:
playlist = await self.get_video_playlist_from_user(
playlist_master_m3u8_obj.playlists
)
if not playlist:
return None
stream_info.stream_url = playlist.uri
stream_info.codec = playlist.stream_info.codecs
stream_info.width, stream_info.height = playlist.stream_info.resolution
playlist_m3u8_obj = m3u8.loads(
(await get_response(stream_info.stream_url)).text
)
stream_info.widevine_pssh = self.get_widevine_pssh(playlist_m3u8_obj)
stream_info.fairplay_key = self.get_fairplay_key(playlist_m3u8_obj)
stream_info.playready_pssh = self.get_playready_pssh(playlist_m3u8_obj)
return stream_info
async def get_stream_info_audio(
self,
playlist_master_data: dict,
codec_priority: list[MusicVideoCodec],
) -> StreamInfo | None:
stream_info = StreamInfo()
if MusicVideoCodec.ASK not in codec_priority:
playlist = self.get_best_stereo_audio_playlist(playlist_master_data)
else:
playlist = await self.get_audio_playlist_from_user(playlist_master_data)
if not playlist:
return None
stream_info.stream_url = playlist["uri"]
stream_info.codec = playlist["group_id"]
playlist_m3u8_obj = m3u8.loads(
(await get_response(stream_info.stream_url)).text
)
stream_info.widevine_pssh = self.get_widevine_pssh(playlist_m3u8_obj)
stream_info.fairplay_key = self.get_fairplay_key(playlist_m3u8_obj)
stream_info.playready_pssh = self.get_playready_pssh(playlist_m3u8_obj)
return stream_info
async def get_decryption_key(
self,
stream_info: StreamInfoAv,
cdm: Cdm,
) -> DecryptionKeyAv:
decryption_key_video = await AppleMusicInterface.get_decryption_key(
self,
stream_info.video_track.widevine_pssh,
stream_info.media_id,
cdm,
)
decryption_key_audio = await AppleMusicInterface.get_decryption_key(
self,
stream_info.audio_track.widevine_pssh,
stream_info.media_id,
cdm,
)
return DecryptionKeyAv(
video_track=decryption_key_video,
audio_track=decryption_key_audio,
)
@@ -1,86 +0,0 @@
import logging
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from ..interface.enums import UploadedVideoQuality
from ..interface.types import MediaTags
from .constants import UPLOADED_VIDEO_QUALITY_RANK
from .interface import AppleMusicInterface
from .types import MediaFileFormat, StreamInfo, StreamInfoAv
logger = logging.getLogger(__name__)
class AppleMusicUploadedVideoInterface(AppleMusicInterface):
def __init__(self, interface: AppleMusicInterface):
self.__dict__.update(interface.__dict__)
def get_stream_url_best(self, metadata: dict) -> str:
best_quality = next(
(
quality
for quality in UPLOADED_VIDEO_QUALITY_RANK
if metadata["attributes"]["assetTokens"].get(quality)
),
None,
)
return metadata["attributes"]["assetTokens"][best_quality]
async def get_stream_url_from_user(self, metadata: dict) -> str:
qualities = list(metadata["attributes"]["assetTokens"].keys())
choices = [
Choice(
name=quality,
value=quality,
)
for quality in qualities
]
selected = await inquirer.select(
message="Select which quality to download:",
choices=choices,
).execute_async()
return metadata["attributes"]["assetTokens"][selected]
async def get_stream_url(
self, metadata: dict, quality: UploadedVideoQuality
) -> str:
if quality == UploadedVideoQuality.BEST:
stream_url = self.get_stream_url_best(metadata)
if quality == UploadedVideoQuality.ASK:
stream_url = await self.get_stream_url_from_user(metadata)
logger.debug(f"Stream URL: {stream_url}")
return stream_url
async def get_stream_info(
self,
metadata: dict,
quality: UploadedVideoQuality,
) -> StreamInfo:
stream_url = await self.get_stream_url(metadata, quality)
stream_info = StreamInfoAv(
file_format=MediaFileFormat.M4V,
video_track=StreamInfo(
stream_url=stream_url,
),
)
return stream_info
def get_tags(self, metadata: dict) -> MediaTags:
attributes = metadata["attributes"]
upload_date = attributes.get("uploadDate")
tags = MediaTags(
artist=attributes.get("artistName"),
date=self.parse_date(upload_date) if upload_date else None,
title=attributes.get("name"),
title_id=int(metadata["id"]),
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
)
logger.debug(f"Tags: {tags}")
return tags
+429
View File
@@ -0,0 +1,429 @@
import asyncio
import urllib.parse
from typing import AsyncGenerator, Callable
import m3u8
import structlog
from .base import AppleMusicBaseInterface
from .constants import MP4_FORMAT_CODECS
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
from .exceptions import (
GamdlInterfaceDecryptionNotAvailableError,
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceMediaNotStreamableError,
)
from .types import (
AppleMusicMedia,
DecryptionKeyAv,
MediaFileFormat,
MediaTags,
StreamInfo,
StreamInfoAv,
)
logger = structlog.get_logger(__name__)
class AppleMusicMusicVideoInterface:
def __init__(
self,
base: AppleMusicBaseInterface,
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
codec_priority: list[MusicVideoCodec] = [
MusicVideoCodec.H264,
MusicVideoCodec.H265,
],
ask_video_codec_function: (
Callable[[list[m3u8.Playlist]], dict | None] | None
) = None,
ask_audio_codec_function: Callable[[list[dict]], dict | None] | None = None,
):
self.base = base
self.resolution = resolution
self.codec_priority = codec_priority
self.ask_video_codec_function = ask_video_codec_function
self.ask_audio_codec_function = ask_audio_codec_function
async def get_itunes_page_metadata(
self,
music_video_metadata: dict,
) -> dict:
url_media_id = self.base.parse_media_id_from_url(music_video_metadata)
itunes_page = await self.base.itunes_api.get_itunes_page(
"music-video",
url_media_id,
)
return itunes_page["storePlatformData"]["product-dv"]["results"][url_media_id]
def _get_m3u8_master_url_from_webplayback(self, webplayback: dict) -> str:
m3u8_master_url = webplayback["hls-playlist-url"]
return m3u8_master_url
def _get_m3u8_master_url_from_itunes_page_metadata(
self,
itunes_page_metadata: dict,
) -> str | None:
stream_url = itunes_page_metadata["offers"][0]["assets"][0].get("hlsUrl")
if not stream_url:
return None
url_parts = urllib.parse.urlparse(stream_url)
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
query.update({"aec": "HD", "dsid": "1"})
m3u8_master_url = url_parts._replace(
query=urllib.parse.urlencode(query, doseq=True)
).geturl()
return m3u8_master_url
async def get_tags(
self,
metadata: dict,
itunes_page_metadata: dict,
) -> MediaTags:
log = logger.bind(
action="get_music_video_tags",
media_id=self.base.parse_catalog_media_id(metadata),
)
url_media_id = self.base.parse_media_id_from_url(metadata)
lookup_metadata = (await self.base.itunes_api.get_lookup_result(url_media_id))[
"results"
]
explicitness = lookup_metadata[0]["trackExplicitness"]
if explicitness == "notExplicit":
rating = MediaRating.NONE
elif explicitness == "explicit":
rating = MediaRating.EXPLICIT
else:
rating = MediaRating.CLEAN
tags = MediaTags(
artist=lookup_metadata[0]["artistName"],
artist_id=int(lookup_metadata[0]["artistId"]),
copyright=itunes_page_metadata.get("copyright"),
date=self.base.parse_date(lookup_metadata[0]["releaseDate"]),
genre=lookup_metadata[0]["primaryGenreName"],
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_id=int(metadata["id"]),
rating=rating,
)
if len(lookup_metadata) > 1:
album = await self.base.get_album_cached(
itunes_page_metadata["collectionId"]
)
if not album:
return tags
tags.album = 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"]
tags.disc_total = lookup_metadata[0]["discCount"]
tags.compilation = album["attributes"]["isCompilation"]
tags.track = lookup_metadata[0]["trackNumber"]
tags.track_total = lookup_metadata[0]["trackCount"]
log.debug("success", tags=tags)
return tags
async def get_stream_info(
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),
)
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(
itunes_page_metadata,
)
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],
)
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_audio = await self._get_stream_info_audio(
playlist_master_m3u8_obj.data,
)
if not stream_info_video or not stream_info_audio:
return None
use_mp4 = any(
stream_info_video.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
) or any(
stream_info_audio.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
)
if use_mp4:
file_format = MediaFileFormat.MP4
else:
file_format = MediaFileFormat.M4V
stream_info = StreamInfoAv(
video_track=stream_info_video,
audio_track=stream_info_audio,
file_format=file_format,
)
log.debug("success", stream_info=stream_info)
return stream_info
def _get_video_playlist_from_resolution(
self,
video_playlists: list[m3u8.Playlist],
) -> 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))
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_resolution = playlist.stream_info.resolution[-1]
bandwidth = playlist.stream_info.bandwidth
exceeds_resolution = playlist_resolution > int(self.resolution)
resolution_difference = abs(playlist_resolution - int(self.resolution))
return (
exceeds_resolution,
resolution_difference,
codec_index,
-playlist_resolution,
-bandwidth,
)
playlist_results.sort(key=sort_key)
return playlist_results[0][1]
def _get_best_stereo_audio_playlist(
self,
playlist_master_data: dict,
) -> dict | None:
audio_playlist = next(
(
media
for media in playlist_master_data["media"]
if media["group_id"] == "audio-stereo-256"
),
None,
)
return audio_playlist
async def _get_video_playlist_from_user(
self,
video_playlists: list[m3u8.Playlist],
) -> m3u8.Playlist | None:
if self.ask_video_codec_function:
video_playlist = self.ask_video_codec_function(video_playlists)
if asyncio.iscoroutine(video_playlist):
video_playlist = await video_playlist
return video_playlist
return None
async def _get_audio_playlist_from_user(
self,
playlist_master_data: dict,
) -> dict | None:
if self.ask_audio_codec_function:
audio_playlist = self.ask_audio_codec_function(
[
playlist
for playlist in playlist_master_data["media"]
if playlist.get("uri")
]
)
if asyncio.iscoroutine(audio_playlist):
audio_playlist = await audio_playlist
return audio_playlist
return None
def _get_key_by_format(
self,
m3u8_obj: m3u8.M3U8,
key_format: str,
) -> str:
return next(
(key for key in m3u8_obj.keys if key.keyformat == key_format),
None,
).uri
def _get_widevine_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
return self._get_key_by_format(
m3u8_obj,
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
)
def _get_playready_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
return self._get_key_by_format(
m3u8_obj,
"com.microsoft.playready",
)
def _get_fairplay_key(self, m3u8_obj: m3u8.M3U8) -> str:
return self._get_key_by_format(
m3u8_obj,
"com.apple.streamingkeydelivery",
)
async def _get_stream_info_video(
self,
playlist_master_m3u8_obj: m3u8.M3U8,
) -> StreamInfo | None:
stream_info = StreamInfo()
if MusicVideoCodec.ASK not in self.codec_priority:
playlist = self._get_video_playlist_from_resolution(
playlist_master_m3u8_obj.playlists,
)
else:
playlist = await self._get_video_playlist_from_user(
playlist_master_m3u8_obj.playlists
)
if not playlist:
return None
stream_info.stream_url = playlist.uri
stream_info.codec = playlist.stream_info.codecs
stream_info.width, stream_info.height = playlist.stream_info.resolution
playlist_m3u8_obj = m3u8.loads(
(await self.base.get_response(stream_info.stream_url)).text
)
stream_info.widevine_pssh = self._get_widevine_pssh(playlist_m3u8_obj)
stream_info.fairplay_key = self._get_fairplay_key(playlist_m3u8_obj)
stream_info.playready_pssh = self._get_playready_pssh(playlist_m3u8_obj)
return stream_info
async def _get_stream_info_audio(
self,
playlist_master_data: dict,
) -> StreamInfo | None:
stream_info = StreamInfo()
if MusicVideoCodec.ASK not in self.codec_priority:
playlist = self._get_best_stereo_audio_playlist(playlist_master_data)
else:
playlist = await self._get_audio_playlist_from_user(playlist_master_data)
if not playlist:
return None
stream_info.stream_url = playlist["uri"]
stream_info.codec = playlist["group_id"]
playlist_m3u8_obj = m3u8.loads(
(await self.base.get_response(stream_info.stream_url)).text
)
stream_info.widevine_pssh = self._get_widevine_pssh(playlist_m3u8_obj)
stream_info.fairplay_key = self._get_fairplay_key(playlist_m3u8_obj)
stream_info.playready_pssh = self._get_playready_pssh(playlist_m3u8_obj)
return stream_info
async def get_decryption_key(
self,
stream_info: StreamInfoAv,
) -> DecryptionKeyAv:
decryption_key_video, decryption_key_audio = await asyncio.gather(
self.base.get_decryption_key(
stream_info.video_track.widevine_pssh,
stream_info.media_id,
),
self.base.get_decryption_key(
stream_info.audio_track.widevine_pssh,
stream_info.media_id,
),
)
return DecryptionKeyAv(
video_track=decryption_key_video,
audio_track=decryption_key_audio,
)
async def get_media(
self,
media: AppleMusicMedia,
) -> AsyncGenerator[AppleMusicMedia, None]:
if not media.media_metadata:
media.media_metadata = (
await self.base.apple_music_api.get_music_video(media.media_id)
)["data"][0]
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
yield media
if not self.base.is_media_streamable(media.media_metadata):
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
if media.playlist_metadata:
media.playlist_tags = self.base.get_playlist_tags(
media.playlist_metadata,
media.index,
)
media.cover = await self.base.get_cover(media.media_metadata)
itunes_page_metadata = await self.get_itunes_page_metadata(media.media_metadata)
media.tags = await self.get_tags(
media.media_metadata,
itunes_page_metadata,
)
media.stream_info = await self.get_stream_info(
media.media_metadata,
itunes_page_metadata,
)
if not media.stream_info:
raise GamdlInterfaceFormatNotAvailableError(
media.media_id,
self.codec_priority,
)
if (
not media.stream_info.video_track.widevine_pssh
or not media.stream_info.audio_track.widevine_pssh
):
raise GamdlInterfaceDecryptionNotAvailableError(media.media_id)
media.decryption_key = await self.get_decryption_key(media.stream_info)
media.partial = False
yield media
@@ -1,26 +1,26 @@
import asyncio
import base64
import datetime
import io
import json
import logging
import re
import struct
from typing import AsyncGenerator, Callable
from xml.dom import minidom
from xml.etree import ElementTree
import m3u8
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from mutagen.mp4 import MP4
from pywidevine import PSSH, Cdm
from pywidevine.license_protocol_pb2 import WidevinePsshData
import structlog
from ..utils import get_response
from .base import AppleMusicBaseInterface
from .constants import DRM_DEFAULT_KEY_MAPPING, MP4_FORMAT_CODECS, SONG_CODEC_REGEX_MAP
from .enums import MediaRating, MediaType, SongCodec, SyncedLyricsFormat
from .interface import AppleMusicInterface
from .exceptions import (
GamdlInterfaceDecryptionNotAvailableError,
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceMediaNotStreamableError,
)
from .types import (
DecryptionKey,
AppleMusicMedia,
DecryptionKeyAv,
Lyrics,
MediaFileFormat,
@@ -29,19 +29,37 @@ from .types import (
StreamInfoAv,
)
logger = logging.getLogger(__name__)
logger = structlog.get_logger(__name__)
class AppleMusicSongInterface(AppleMusicInterface):
def __init__(self, interface: AppleMusicInterface):
self.__dict__.update(interface.__dict__)
class AppleMusicSongInterface:
def __init__(
self,
base: AppleMusicBaseInterface,
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
codec_priority: list[SongCodec] = [SongCodec.AAC_LEGACY],
use_album_date: bool = False,
skip_stream_info: bool = False,
ask_codec_function: Callable[[list[dict]], dict | None] | None = None,
):
self.base = base
self.synced_lyrics_format = synced_lyrics_format
self.codec_priority = codec_priority
self.use_album_date = use_album_date
self.skip_stream_info = skip_stream_info
self.ask_codec_function = ask_codec_function
async def get_lyrics(
self,
song_metadata: dict,
synced_lyrics_format: SyncedLyricsFormat,
) -> Lyrics | None:
log = logger.bind(
action="get_lyrics",
song_id=self.base.parse_catalog_media_id(song_metadata),
)
if not song_metadata["attributes"]["hasLyrics"]:
log.debug("no_lyrics")
return None
if (
@@ -49,8 +67,8 @@ class AppleMusicSongInterface(AppleMusicInterface):
or "lyrics" not in song_metadata["relationships"]
):
song_metadata = (
await self.apple_music_api.get_song(
self.get_media_id_of_library_media(song_metadata)
await self.base.apple_music_api.get_song(
self.base.parse_catalog_media_id(song_metadata)
)
)["data"][0]
@@ -68,16 +86,17 @@ class AppleMusicSongInterface(AppleMusicInterface):
song_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
"ttml"
],
synced_lyrics_format,
)
logging.debug(f"Lyrics: {lyrics}")
log.debug("success", lyrics=lyrics)
return lyrics
else:
log.debug("no_lyrics_data")
def _get_lyrics(
self,
lyrics_ttml: str,
synced_lyrics_format: SyncedLyricsFormat,
) -> Lyrics:
lyrics_ttml_et = ElementTree.fromstring(lyrics_ttml)
unsynced_lyrics = []
@@ -93,13 +112,13 @@ class AppleMusicSongInterface(AppleMusicInterface):
stanza.append(p.text)
if p.attrib.get("begin"):
if synced_lyrics_format == SyncedLyricsFormat.LRC:
if self.synced_lyrics_format == SyncedLyricsFormat.LRC:
synced_lyrics.append(self._get_lyrics_line_lrc(p))
if synced_lyrics_format == SyncedLyricsFormat.SRT:
if self.synced_lyrics_format == SyncedLyricsFormat.SRT:
synced_lyrics.append(self._get_lyrics_line_srt(index, p))
if synced_lyrics_format == SyncedLyricsFormat.TTML:
if self.synced_lyrics_format == SyncedLyricsFormat.TTML:
if not synced_lyrics:
synced_lyrics.append(
minidom.parseString(lyrics_ttml).toprettyxml()
@@ -174,8 +193,9 @@ class AppleMusicSongInterface(AppleMusicInterface):
self,
webplayback: dict,
lyrics: str | None = None,
use_album_date: bool = False,
) -> MediaTags:
log = logger.bind(action="get_song_tags")
webplayback_metadata = webplayback["songList"][0]["assets"][0]["metadata"]
tags = MediaTags(
@@ -197,10 +217,10 @@ class AppleMusicSongInterface(AppleMusicInterface):
composer_sort=webplayback_metadata.get("sort-composer"),
copyright=webplayback_metadata.get("copyright"),
date=(
await self.get_media_date(webplayback_metadata["playlistId"])
if use_album_date
await self.base.get_media_date(webplayback_metadata["playlistId"])
if self.use_album_date
else (
self.parse_date(webplayback_metadata["releaseDate"])
self.base.parse_date(webplayback_metadata["releaseDate"])
if webplayback_metadata.get("releaseDate")
else None
)
@@ -221,22 +241,66 @@ class AppleMusicSongInterface(AppleMusicInterface):
track_total=webplayback_metadata["trackCount"],
xid=webplayback_metadata.get("xid"),
)
logger.debug(f"Tags: {tags}")
log.debug("success", tags=tags)
return tags
async def get_stream_info(
self,
song_metadata: dict | None = None,
webplayback: dict | None = None,
) -> StreamInfoAv | None:
for codec in self.codec_priority:
if codec.is_legacy():
return await self._get_stream_info_legacy(webplayback, codec)
else:
return await self._get_stream_info(song_metadata, codec)
async def get_wrapper_m3u8(self, adam_id: str) -> str | None:
host, port = self.base.wrapper_m3u8_ip.split(":")
reader, writer = await asyncio.open_connection(host, port)
data = struct.pack("B", len(adam_id)) + adam_id.encode()
writer.write(data)
await writer.drain()
response = await reader.readuntil(b"\n")
m3u8_url = response.decode().strip()
writer.close()
await writer.wait_closed()
if m3u8_url:
return m3u8_url
return None
async def _get_stream_info(
self,
song_metadata: dict,
codec: SongCodec,
) -> StreamInfoAv | None:
m3u8_master_url = song_metadata["attributes"]["extendedAssetUrls"].get(
"enhancedHls"
log = logger.bind(action="get_song_stream_info")
if "extendedAssetUrls" not in song_metadata["attributes"]:
song_metadata = (
await self.base.apple_music_api.get_song(
self.base.parse_catalog_media_id(song_metadata),
)
)["data"][0]
m3u8_master_url = (
await self.get_wrapper_m3u8(self.base.parse_catalog_media_id(song_metadata))
if self.base.use_wrapper
else song_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
)
if not m3u8_master_url:
return None
m3u8_master_obj = m3u8.loads((await get_response(m3u8_master_url)).text)
m3u8_master_obj = m3u8.loads(
(await self.base.get_response(m3u8_master_url)).text
)
m3u8_master_data = m3u8_master_obj.data
if codec == SongCodec.ASK:
@@ -248,9 +312,10 @@ class AppleMusicSongInterface(AppleMusicInterface):
)
if playlist is None:
log.debug("no_matching_playlist", codec=codec.value)
return None
stream_info = StreamInfo()
stream_info = StreamInfo(legacy=False)
stream_info.stream_url = (
f"{m3u8_master_url.rpartition('/')[0]}/{playlist['uri']}"
)
@@ -280,7 +345,9 @@ class AppleMusicSongInterface(AppleMusicInterface):
"com.apple.streamingkeydelivery",
)
else:
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
m3u8_obj = m3u8.loads(
(await self.base.get_response(stream_info.stream_url)).text
)
stream_info.widevine_pssh = self._get_drm_uri_from_m3u8_keys(
m3u8_obj,
@@ -299,7 +366,8 @@ class AppleMusicSongInterface(AppleMusicInterface):
audio_track=stream_info,
file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A,
)
logger.debug(f"Stream info: {stream_info_av}")
log.debug("success", stream_info=stream_info_av)
return stream_info_av
@@ -343,18 +411,16 @@ class AppleMusicSongInterface(AppleMusicInterface):
)
async def _get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
choices = [
Choice(
name=playlist["stream_info"]["audio"],
value=playlist,
if self.ask_codec_function:
playlist = self.ask_codec_function(
[playlist for playlist in m3u8_data["playlists"]]
)
for playlist in m3u8_data["playlists"]
]
if asyncio.iscoroutine(playlist):
playlist = await playlist
return await inquirer.select(
message="Select which codec to download:",
choices=choices,
).execute_async()
return playlist
return None
def _get_drm_uri_from_session_key(
self,
@@ -379,19 +445,23 @@ class AppleMusicSongInterface(AppleMusicInterface):
return key.uri
return None
async def get_stream_info_legacy(
async def _get_stream_info_legacy(
self,
webplayback: dict,
codec: SongCodec,
) -> StreamInfoAv:
log = logger.bind(action="get_legacy_song_stream_info")
flavor = "32:ctrp64" if codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
stream_info = StreamInfo()
stream_info = StreamInfo(legacy=True)
stream_info.stream_url = next(
i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor
)["URL"]
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
m3u8_obj = m3u8.loads(
(await self.base.get_response(stream_info.stream_url)).text
)
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
stream_info_av = StreamInfoAv(
@@ -399,84 +469,75 @@ class AppleMusicSongInterface(AppleMusicInterface):
audio_track=stream_info,
file_format=MediaFileFormat.M4A,
)
logger.debug(f"Stream info legacy: {stream_info_av}")
log.debug("success", stream_info=stream_info_av)
return stream_info_av
async def get_decryption_key_legacy(
async def get_media(
self,
stream_info: StreamInfoAv,
cdm: Cdm,
) -> DecryptionKeyAv:
stream_info_audio = stream_info.audio_track
media: AppleMusicMedia,
) -> AsyncGenerator[AppleMusicMedia, None]:
if not media.media_metadata:
media.media_metadata = (
await self.base.apple_music_api.get_song(media.media_id)
)["data"][0]
try:
cdm_session = cdm.open()
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
widevine_pssh_data = WidevinePsshData()
widevine_pssh_data.algorithm = 1
widevine_pssh_data.key_ids.append(
base64.b64decode(stream_info_audio.widevine_pssh.split(",")[1])
yield media
if not self.base.is_media_streamable(media.media_metadata):
raise GamdlInterfaceMediaNotStreamableError(
media_id=media.media_id,
)
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
challenge = base64.b64encode(
await asyncio.to_thread(
cdm.get_license_challenge, cdm_session, pssh_obj
if media.playlist_metadata:
media.playlist_tags = self.base.get_playlist_tags(
media.playlist_metadata,
media.index,
)
media.cover = await self.base.get_cover(media.media_metadata)
media.lyrics = await self.get_lyrics(media.media_metadata)
webplayback = await self.base.apple_music_api.get_webplayback(media.media_id)
media.tags = await self.get_tags(
webplayback,
media.lyrics.unsynced if media.lyrics else None,
)
if not self.skip_stream_info:
media.stream_info = await self.get_stream_info(
media.media_metadata,
webplayback,
)
if not media.stream_info:
raise GamdlInterfaceFormatNotAvailableError(
media_id=media.media_id,
codec=self.codec_priority,
)
).decode()
license_response = await self.apple_music_api.get_license_exchange(
stream_info.media_id,
stream_info.audio_track.widevine_pssh,
challenge,
)
await asyncio.to_thread(
cdm.parse_license, cdm_session, license_response["license"]
)
if (
not self.base.use_wrapper
and not media.stream_info.audio_track.widevine_pssh
) or (
self.base.use_wrapper and not media.stream_info.audio_track.fairplay_key
):
raise GamdlInterfaceDecryptionNotAvailableError(media_id=media.media_id)
decryption_key = next(
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
)
finally:
cdm.close(cdm_session)
if (
media.stream_info.audio_track.widevine_pssh
and not self.base.use_wrapper
) or media.stream_info.audio_track.legacy:
media.decryption_key = DecryptionKeyAv(
audio_track=await self.base.get_decryption_key(
media.stream_info.audio_track.widevine_pssh,
media.media_id,
)
)
decryption_key = DecryptionKeyAv(
audio_track=DecryptionKey(
kid=decryption_key.kid.hex,
key=decryption_key.key.hex(),
)
)
logger.debug(f"Decryption key legacy: {decryption_key}")
media.partial = False
return decryption_key
async def get_decryption_key(
self,
stream_info: StreamInfoAv,
cdm: Cdm,
) -> DecryptionKeyAv:
return DecryptionKeyAv(
audio_track=await AppleMusicInterface.get_decryption_key(
self,
stream_info.audio_track.widevine_pssh,
stream_info.media_id,
cdm,
)
)
async def get_extra_tags(
self,
song_metadata: dict,
) -> dict:
previews = song_metadata["attributes"].get("previews", [])
if not previews:
return {}
preview_url = previews[0]["url"]
preview_response = await get_response(preview_url)
preview_bytes = preview_response.content
preview_tags = dict(MP4(io.BytesIO(preview_bytes)).tags)
logger.debug(f"Extra tags: {preview_tags.keys()}")
return preview_tags
yield media
+42 -3
View File
@@ -1,5 +1,6 @@
import datetime
from dataclasses import dataclass
from typing import Any
from .enums import MediaFileFormat, MediaRating, MediaType
@@ -106,10 +107,10 @@ class MediaTags:
@dataclass
class PlaylistTags:
playlist_artist: str = None
artist: str = None
playlist_id: int = None
playlist_title: str = None
playlist_track: int = None
title: str = None
track: int = None
@dataclass
@@ -121,6 +122,7 @@ class StreamInfo:
codec: str = None
width: int = None
height: int = None
legacy: bool = None
@dataclass
@@ -141,3 +143,40 @@ class DecryptionKey:
class DecryptionKeyAv:
video_track: DecryptionKey = None
audio_track: DecryptionKey = None
@dataclass
class Cover:
template_url: str = None
file_extension: str = None
url: str = None
@dataclass
class AppleMusicMedia:
media_id: str
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
extra_tags: dict | None = None
cover: Cover | None = None
lyrics: Lyrics | None = None
tags: MediaTags | None = None
stream_info: StreamInfoAv | None = None
decryption_key: DecryptionKeyAv | None = None
@dataclass
class AppleMusicUrlInfo:
storefront: str = None
type: str = None
slug: str = None
id: str = None
sub_id: str = None
library_storefront: str = None
library_type: str = None
library_id: str = None
+133
View File
@@ -0,0 +1,133 @@
import asyncio
from collections.abc import Callable
from typing import AsyncGenerator
import structlog
from .base import AppleMusicBaseInterface
from .constants import UPLOADED_VIDEO_QUALITY_RANK
from .enums import UploadedVideoQuality
from .exceptions import (
GamdlInterfaceFormatNotAvailableError,
GamdlInterfaceMediaNotStreamableError,
)
from .types import AppleMusicMedia, MediaFileFormat, MediaTags, StreamInfo, StreamInfoAv
logger = structlog.get_logger(__name__)
class AppleMusicUploadedVideoInterface:
def __init__(
self,
base: AppleMusicBaseInterface,
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
ask_quality_function: Callable[[dict], dict | None] | None = None,
):
self.base = base
self.quality = quality
self.ask_quality_function = ask_quality_function
def _get_best_stream_url(self, metadata: dict) -> str:
best_quality = next(
(
quality
for quality in UPLOADED_VIDEO_QUALITY_RANK
if metadata["attributes"]["assetTokens"].get(quality)
),
None,
)
return metadata["attributes"]["assetTokens"][best_quality]
async def _get_stream_url_from_user(self, metadata: dict) -> str | None:
if self.ask_quality_function:
selected_quality = self.ask_quality_function(
metadata["attributes"]["assetTokens"]
)
if asyncio.iscoroutine(selected_quality):
selected_quality = await selected_quality
return selected_quality
return None
async def _get_stream_url(
self,
metadata: dict,
) -> str | None:
if self.quality == UploadedVideoQuality.BEST:
stream_url = self._get_best_stream_url(metadata)
if self.quality == UploadedVideoQuality.ASK:
stream_url = await self._get_stream_url_from_user(metadata)
return stream_url
async def get_stream_info(
self,
metadata: dict,
) -> StreamInfo | None:
log = logger.bind(
action="get_uploaded_video_stream_info", media_id=metadata["id"]
)
stream_url = await self._get_stream_url(metadata)
if not stream_url:
log.debug("no_stream_url_available")
return None
stream_info = StreamInfoAv(
file_format=MediaFileFormat.M4V,
video_track=StreamInfo(
stream_url=stream_url,
),
)
log.debug("success", stream_info=stream_info)
return stream_info
def get_tags(self, metadata: dict) -> MediaTags:
log = logger.bind(action="get_uploaded_video_tags", media_id=metadata["id"])
attributes = metadata["attributes"]
upload_date = attributes.get("uploadDate")
tags = MediaTags(
artist=attributes.get("artistName"),
date=self.base.parse_date(upload_date) if upload_date else None,
title=attributes.get("name"),
title_id=int(metadata["id"]),
storefront=self.base.itunes_api.storefront_id,
)
log.debug("success", tags=tags)
return tags
async def get_media(
self,
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]
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
yield media
if not self.base.is_media_streamable(media.media_metadata):
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
media.cover = await self.base.get_cover(media.media_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(media.media_metadata)
media.partial = False
yield media
+3
View File
@@ -0,0 +1,3 @@
# Dumped from Android Studio Virtual Device running Android 9
WVD = """V1ZEAgIDAASoMIIEpAIBAAKCAQEAwnCFAPXy4U1J7p1NohAS+xl040f5FBaE/59bPp301bGz0UGFT9VoEtY3vaeakKh/d319xTNvCSWsEDRaMmp/wSnMiEZUkkl04872jx2uHuR4k6KYuuJoqhsIo1TwUBueFZynHBUJzXQeW8Eb1tYAROGwp8W7r+b0RIjHC89RFnfVXpYlF5I6McktyzJNSOwlQbMqlVihfSUkv3WRd3HFmA0Oxay51CEIkoTlNTHVlzVyhov5eHCDSp7QENRgaaQ03jC/CcgFOoQymhsBtRCM0CQmfuAHjA9e77R6m/GJPy75G9fqoZM1RMzVDHKbKZPd3sFd0c0+77gLzW8cWEaaHwIDAQABAoIBAQCB2pN46MikHvHZIcTPDt0eRQoDH/YArGl2Lf7J+sOgU2U7wv49KtCug9IGHwDiyyUVsAFmycrF2RroV45FTUq0vi2SdSXV7Kjb20Ren/vBNeQw9M37QWmU8Sj7q6YyWb9hv5T69DHvvDTqIjVtbM4RMojAAxYti5hmjNIh2PrWfVYWhXxCQ/WqAjWLtZBM6Oww1byfr5I/wFogAKkgHi8wYXZ4LnIC8V7jLAhujlToOvMMC9qwcBiPKDP2FO+CPSXaqVhH+LPSEgLggnU3EirihgxovbLNAuDEeEbRTyR70B0lW19tLHixso4ZQa7KxlVUwOmrHSZf7nVuWqPpxd+BAoGBAPQLyJ1IeRavmaU8XXxfMdYDoc8+xB7v2WaxkGXb6ToX1IWPkbMz4yyVGdB5PciIP3rLZ6s1+ruuRRV0IZ98i1OuN5TSR56ShCGg3zkd5C4L/xSMAz+NDfYSDBdO8BVvBsw21KqSRUi1ctL7QiIvfedrtGb5XrE4zhH0gjXlU5qZAoGBAMv2segn0Jx6az4rqRa2Y7zRx4iZ77JUqYDBI8WMnFeR54uiioTQ+rOs3zK2fGIWlrn4ohco/STHQSUTB8oCOFLMx1BkOqiR+UyebO28DJY7+V9ZmxB2Guyi7W8VScJcIdpSOPyJFOWZQKXdQFW3YICD2/toUx/pDAJh1sEVQsV3AoGBANyyp1rthmvoo5cVbymhYQ08vaERDwU3PLCtFXu4E0Ow90VNn6Ki4ueXcv/gFOp7pISk2/yuVTBTGjCblCiJ1en4HFWekJwrvgg3Vodtq8Okn6pyMCHRqvWEPqD5hw6rGEensk0K+FMXnF6GULlfn4mgEkYpb+PvDhSYvQSGfkPJAoGAF/bAKFqlM/1eJEvU7go35bNwEiij9Pvlfm8y2L8Qj2lhHxLV240CJ6IkBz1Rl+S3iNohkT8LnwqaKNT3kVB5daEBufxMuAmOlOX4PmZdxDj/r6hDg8ecmjj6VJbXt7JDd/c5ItKoVeGPqu035dpJyE+1xPAY9CLZel4scTsiQTkCgYBt3buRcZMwnc4qqpOOQcXK+DWD6QvpkcJ55ygHYw97iP/lF4euwdHd+I5b+11pJBAao7G0fHX3eSjqOmzReSKboSe5L8ZLB2cAI8AsKTBfKHWmCa8kDtgQuI86fUfirCGdhdA9AVP2QXN2eNCuPnFWi0WHm4fYuUB5be2c18ucxAb9CAESmgsK3QMIAhIQ071yBlsbLoO2CSB9Ds0cmRif6uevBiKOAjCCAQoCggEBAMJwhQD18uFNSe6dTaIQEvsZdONH+RQWhP+fWz6d9NWxs9FBhU/VaBLWN72nmpCof3d9fcUzbwklrBA0WjJqf8EpzIhGVJJJdOPO9o8drh7keJOimLriaKobCKNU8FAbnhWcpxwVCc10HlvBG9bWAEThsKfFu6/m9ESIxwvPURZ31V6WJReSOjHJLcsyTUjsJUGzKpVYoX0lJL91kXdxxZgNDsWsudQhCJKE5TUx1Zc1coaL+Xhwg0qe0BDUYGmkNN4wvwnIBTqEMpobAbUQjNAkJn7gB4wPXu+0epvxiT8u+RvX6qGTNUTM1QxymymT3d7BXdHNPu+4C81vHFhGmh8CAwEAASjwIkgBUqoBCAEQABqBAQQlRbfiBNDb6eU6aKrsH5WJaYszTioXjPLrWN9dqyW0vwfT11kgF0BbCGkAXew2tLJJqIuD95cjJvyGUSN6VyhL6dp44fWEGDSBIPR0mvRq7bMP+m7Y/RLKf83+OyVJu/BpxivQGC5YDL9f1/A8eLhTDNKXs4Ia5DrmTWdPTPBL8SIgyfUtg3ofI+/I9Tf7it7xXpT0AbQBJfNkcNXGpO3JcBMSgAIL5xsXK5of1mMwAl6ygN1Gsj4aZ052otnwN7kXk12SMsXheWTZ/PYh2KRzmt9RPS1T8hyFx/Kp5VkBV2vTAqqWrGw/dh4URqiHATZJUlhO7PN5m2Kq1LVFdXjWSzP5XBF2S83UMe+YruNHpE5GQrSyZcBqHO0QrdPcU35GBT7S7+IJr2AAXvnjqnb8yrtpPWN2ZW/IWUJN2z4vZ7/HV4aj3OZhkxC1DIMNyvsusUKoQQuf8gwKiEe8cFwbwFSicywlFk9la2IPe8oFShcxAzHLCCn/TIYUAvEL3/4LgaZvqWm80qCPYbgIP5HT8hPYkKWJ4WYknEWK+3InbnkzteFfGrQFCq4CCAESEGnj6Ji7LD+4o7MoHYT4jBQYjtW+kQUijgIwggEKAoIBAQDY9um1ifBRIOmkPtDZTqH+CZUBbb0eK0Cn3NHFf8MFUDzPEz+emK/OTub/hNxCJCao//pP5L8tRNUPFDrrvCBMo7Rn+iUb+mA/2yXiJ6ivqcN9Cu9i5qOU1ygon9SWZRsujFFB8nxVreY5Lzeq0283zn1Cg1stcX4tOHT7utPzFG/ReDFQt0O/GLlzVwB0d1sn3SKMO4XLjhZdncrtF9jljpg7xjMIlnWJUqxDo7TQkTytJmUl0kcM7bndBLerAdJFGaXc6oSY4eNy/IGDluLCQR3KZEQsy/mLeV1ggQ44MFr7XOM+rd+4/314q/deQbjHqjWFuVr8iIaKbq+R63ShAgMBAAEo8CISgAMii2Mw6z+Qs1bvvxGStie9tpcgoO2uAt5Zvv0CDXvrFlwnSbo+qR71Ru2IlZWVSbN5XYSIDwcwBzHjY8rNr3fgsXtSJty425djNQtF5+J2jrAhf3Q2m7EI5aohZGpD2E0cr+dVj9o8x0uJR2NWR8FVoVQSXZpad3M/4QzBLNto/tz+UKyZwa7Sc/eTQc2+ZcDS3ZEO3lGRsH864Kf/cEGvJRBBqcpJXKfG+ItqEW1AAPptjuggzmZEzRq5xTGf6or+bXrKjCpBS9G1SOyvCNF1k5z6lG8KsXhgQxL6ADHMoulxvUIihyPY5MpimdXfUdEQ5HA2EqNiNVNIO4qP007jW51yAeThOry4J22xs8RdkIClOGAauLIl0lLA4flMzW+VfQl5xYxP0E5tuhn0h+844DslU8ZF7U1dU2QprIApffXD9wgAACk26Rggy8e96z8i86/+YYyZQkc9hIdCAERrgEYCEbByzONrdRDs1MrS/ch1moV5pJv63BIKvQHGvLkaFwoMY29tcGFueV9uYW1lEgd1bmtub3duGioKCm1vZGVsX25hbWUSHEFuZHJvaWQgU0RLIGJ1aWx0IGZvciB4ODZfNjQaGwoRYXJjaGl0ZWN0dXJlX25hbWUSBng4Nl82NBodCgtkZXZpY2VfbmFtZRIOZ2VuZXJpY194ODZfNjQaIAoMcHJvZHVjdF9uYW1lEhBzZGtfcGhvbmVfeDg2XzY0GmMKCmJ1aWxkX2luZm8SVUFuZHJvaWQvc2RrX3Bob25lX3g4Nl82NC9nZW5lcmljX3g4Nl82NDo5L1BTUjEuMTgwNzIwLjAxMi80OTIzMjE0OnVzZXJkZWJ1Zy90ZXN0LWtleXMaHgoUd2lkZXZpbmVfY2RtX3ZlcnNpb24SBjE0LjAuMBokCh9vZW1fY3J5cHRvX3NlY3VyaXR5X3BhdGNoX2xldmVsEgEwMg4QASAAKA0wAEAASABQAA=="""
+4 -43
View File
@@ -1,35 +1,8 @@
import asyncio
import json
import string
import subprocess
import typing
import httpx
def raise_for_status(httpx_response: httpx.Response, valid_responses: set[int] = {200}):
if httpx_response.status_code not in valid_responses:
raise httpx._exceptions.HTTPError(
f"HTTP error {httpx_response.status_code}: {httpx_response.text}"
)
def safe_json(httpx_response: httpx.Response) -> dict:
try:
return httpx_response.json()
except (json.JSONDecodeError, UnicodeDecodeError):
return {}
async def get_response(
url: str,
valid_responses: set[int] = {200},
) -> httpx.Response:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.get(url)
raise_for_status(response, valid_responses)
return response
async def async_subprocess(*args: str, silent: bool = False) -> None:
if silent:
@@ -66,22 +39,6 @@ async def safe_gather(
)
async def sequential_gather(
*tasks: typing.Awaitable[typing.Any],
interval: float = 0.5,
) -> list[typing.Any]:
results = []
for i, task in enumerate(tasks):
try:
result = await task
results.append(result)
except Exception as e:
results.append(e)
if interval > 0 and i < len(tasks) - 1:
await asyncio.sleep(interval)
return results
class CustomStringFormatter(string.Formatter):
def format_field(self, value: typing.Any, format_spec: str) -> str:
if isinstance(value, tuple) and len(value) == 2:
@@ -95,3 +52,7 @@ class CustomStringFormatter(string.Formatter):
return fallback_value
return super().format_field(value, format_spec)
class GamdlError(Exception):
pass
+9 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gamdl"
version = "2.8.4"
version = "3.2"
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
readme = "README.md"
license = "MIT"
@@ -11,11 +11,13 @@ dependencies = [
"colorama>=0.4.6",
"dataclass-click>=1.0.4",
"httpx>=0.28.1",
"httpx-retries>=0.4.6",
"inquirerpy>=0.3.4",
"m3u8>=6.0.0",
"mutagen>=1.47.0",
"pillow>=12.0.0",
"pywidevine>=1.8.0",
"structlog>=25.5.0",
"yt-dlp>=2025.10.22",
]
@@ -24,3 +26,9 @@ Repository = "https://github.com/glomatico/gamdl"
[project.scripts]
gamdl = "gamdl.cli.cli:main"
[dependency-groups]
dev = [
"pytest>=9.0.3",
"pytest-asyncio>=1.3.0",
]
Generated
+172 -1
View File
@@ -29,6 +29,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" },
]
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]]
name = "backports-datetime-fromisoformat"
version = "2.0.3"
@@ -214,7 +223,7 @@ wheels = [
[[package]]
name = "gamdl"
version = "2.8.4"
version = "3.2"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },
@@ -222,14 +231,22 @@ dependencies = [
{ name = "colorama" },
{ name = "dataclass-click" },
{ name = "httpx" },
{ name = "httpx-retries" },
{ name = "inquirerpy" },
{ name = "m3u8" },
{ name = "mutagen" },
{ name = "pillow" },
{ name = "pywidevine" },
{ name = "structlog" },
{ name = "yt-dlp" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "async-lru", specifier = ">=2.0.5" },
@@ -237,14 +254,22 @@ requires-dist = [
{ name = "colorama", specifier = ">=0.4.6" },
{ name = "dataclass-click", specifier = ">=1.0.4" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "httpx-retries", specifier = ">=0.4.6" },
{ name = "inquirerpy", specifier = ">=0.3.4" },
{ name = "m3u8", specifier = ">=6.0.0" },
{ name = "mutagen", specifier = ">=1.47.0" },
{ name = "pillow", specifier = ">=12.0.0" },
{ name = "pywidevine", specifier = ">=1.8.0" },
{ name = "structlog", specifier = ">=25.5.0" },
{ name = "yt-dlp", specifier = ">=2025.10.22" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -282,6 +307,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "httpx-retries"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/13/5eac2df576c02280f79e4639a6d4c93a25cfe94458275f5aa55f5e6c8ea0/httpx_retries-0.4.6.tar.gz", hash = "sha256:a076d8a5ede5d5794e9c241da17b15b393b482129ddd2fdf1fa56a3fa1f28a7f", size = 13466, upload-time = "2026-02-17T16:16:05.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/97/63f56da4400034adde22adfe7524635dba068f17d6858f92ecd96f55b53e/httpx_retries-0.4.6-py3-none-any.whl", hash = "sha256:d66d912173b844e065ffb109345a453b922f4c2cd9c9e11139304cb33e7a1ee1", size = 8490, upload-time = "2026-02-17T16:16:04.137Z" },
]
[[package]]
name = "idna"
version = "3.11"
@@ -291,6 +328,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "inquirerpy"
version = "0.3.4"
@@ -325,6 +371,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pfzy"
version = "0.3.4"
@@ -432,6 +487,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "prompt-toolkit"
version = "3.0.52"
@@ -493,6 +557,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pymp4"
version = "1.4.0"
@@ -505,6 +578,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/a2/27fea39af627c0ce5dbf6108bf969ea8f5fc9376d29f11282a80e3426f1d/pymp4-1.4.0-py3-none-any.whl", hash = "sha256:3401666c1e2a97ac94dffb18c5a5dcbd46d0a436da5272d378a6f9f6506dd12d", size = 14832, upload-time = "2023-05-07T15:01:32.293Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "pywidevine"
version = "1.8.0"
@@ -611,6 +716,72 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "structlog"
version = "25.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"