Compare commits

..

113 Commits

Author SHA1 Message Date
Rafael Moraes ade78ad7b3 Bump version to 2.8.2 2025-12-21 16:17:01 -03:00
Rafael Moraes 054f636434 Bump version to 2.8.2 2025-12-21 16:07:48 -03:00
Rafael Moraes bf9c74d9d8 Increase concurrency limit in safe_gather to 10 2025-12-21 16:07:33 -03:00
Rafael Moraes 3c48618e84 Remove custom transport retries from AppleMusicApi 2025-12-21 15:56:30 -03:00
Rafael Moraes c940ee2f47 Replace sequential_gather with safe_gather in downloader 2025-12-21 15:55:07 -03:00
Rafael Moraes 7f56dfd0c8 Remove retry logic from safe_gather utility 2025-12-21 15:54:52 -03:00
Rafael Moraes 7c3112421d Refactor AppleMusicApi.create_from_wrapper to use get_response utility 2025-12-21 01:48:16 -03:00
Rafael Moraes 55ce7555a9 Add timeout to iTunes API search request 2025-12-21 01:36:03 -03:00
Rafael Moraes 9c4adbb2c1 Refactor HTTP response handling for m3u8 and cover fetch 2025-12-21 01:34:09 -03:00
Rafael Moraes 1591f0daf2 Set httpx.AsyncClient timeout to 60 seconds 2025-12-18 14:21:56 -03:00
Rafael Moraes 25d028bea4 Add colorama for improved Windows console support 2025-12-14 19:13:30 -03:00
Rafael Moraes ebc28a019e Bump version to 2.8.1 2025-12-10 01:23:32 -03:00
Rafael Moraes 690df6e9d7 Update README example for AppleMusicApi usage 2025-12-10 01:12:52 -03:00
Rafael Moraes 8039c7c86f Reorder error check in AppleMusicDownloader 2025-12-10 01:08:13 -03:00
Rafael Moraes f67ba37d19 Check streamability before downloading media 2025-12-09 23:26:53 -03:00
Rafael Moraes 59f247a90f Fix default language option in CLI 2025-12-06 15:41:44 -03:00
Rafael Moraes 181bdb198d Refactor AppleMusicApi init and factory methods 2025-12-06 15:40:45 -03:00
Rafael Moraes 1945342adc Improve audio track validation in AppleMusicDownloader 2025-12-05 01:11:31 -03:00
Rafael Moraes f19ef4d6dd Fix audio track validation in AppleMusicDownloader 2025-12-05 01:05:44 -03:00
Rafael Moraes 1ceb7fcf46 Instantiate ItunesApi directly in CLI 2025-12-04 17:28:23 -03:00
Rafael Moraes 23ed14ca04 Refactor ItunesApi instantiation and initialization 2025-12-04 17:27:59 -03:00
Rafael Moraes 3e3939d0ee Refactor downloader setup to initialization method 2025-12-04 17:26:35 -03:00
Rafael Moraes 780261a9c8 Update API instantiation to use async factory methods 2025-12-04 17:24:41 -03:00
Rafael Moraes 80cb80e9a2 Refactor AppleMusicApi and ItunesApi initialization 2025-12-04 17:24:32 -03:00
Rafael Moraes f3b7adaad3 Replace safe_gather with sequential_gather in downloader 2025-12-04 16:52:34 -03:00
Rafael Moraes fe6a6e308d Refactor mp4decrypt and amdecrypt path checks in CLI 2025-11-29 14:27:28 -03:00
Rafael Moraes b08bf98759 Reduce retry count in safe_gather utility 2025-11-29 14:24:58 -03:00
Rafael Moraes 37c857b503 Bump version to 2.8 2025-11-28 19:18:32 -03:00
Rafael Moraes 4693ba69c9 Merge branch 'wrapper' 2025-11-28 19:16:44 -03:00
Rafael Moraes 9212319d3b Remove unused STOREFRONT_IDS import 2025-11-28 18:49:23 -03:00
Rafael Moraes e54f318c36 Add wrapper & amdecrypt instructions to README 2025-11-28 11:23:39 -03:00
Rafael Moraes b1e40299ca Refactor AppleMusicApi token setup logic 2025-11-28 00:15:17 -03:00
Rafael Moraes ba86825068 Merge pull request #252 from fredystar200/patch-1
Add constant for 'CM' in constants.py (Cameroon)
2025-11-27 21:09:11 -03:00
Rafael Moraes b5f08753b8 Rename use_wrapper_decrypt to use_wrapper 2025-11-27 18:16:32 -03:00
Rafael Moraes d4bf75c0d1 Rename enable_wrapper_decrypt to use_wrapper_decrypt 2025-11-27 16:09:11 -03:00
Rafael Moraes e998ce1a2e Add support for FairPlay and PlayReady PSSH extraction 2025-11-27 15:17:13 -03:00
Rafael Moraes 5285ca0cfa Update warning for experimental song codec usage 2025-11-27 15:03:57 -03:00
Rafael Moraes f3927b8e6d Add wrapper decryption options to CLI 2025-11-27 15:02:53 -03:00
Rafael Moraes 40b7ce05d3 Fix decryption key check in AppleMusicDownloader 2025-11-27 15:02:47 -03:00
Rafael Moraes 8cd01e7964 Refactor wrapper_decrypt_ip handling in downloaders 2025-11-27 15:02:40 -03:00
Rafael Moraes f769c6b686 Refactor wrapper decrypt flag handling in downloaders 2025-11-27 14:46:56 -03:00
Rafael Moraes ea7356e7c4 Add amdecrypt support for wrapper-based decryption 2025-11-27 14:44:29 -03:00
Rafael Moraes f3d8242110 Add from_wrapper constructor to AppleMusicApi 2025-11-27 14:34:35 -03:00
Rafael Moraes faf3bb3a20 Add optional token parameter to AppleMusicApi 2025-11-27 12:59:59 -03:00
Rafael Moraes 24c3ce8a02 Handle missing stream info in staged path assignment 2025-11-27 11:18:28 -03:00
Rafael Moraes 65eb8c0fb6 Simplify decryption key validation logic 2025-11-27 11:14:04 -03:00
Rafael Moraes f90be057d6 Add decryption key checks to AppleMusicDownloader 2025-11-27 11:10:57 -03:00
Rafael Moraes 76cc80cba8 Refactor error handling to use GamdlError 2025-11-27 00:55:32 -03:00
Rafael Moraes 7a7c1adb22 Check for widevine_pssh in audio track before download 2025-11-27 00:54:40 -03:00
Rafael Moraes 200e392fad Refactor exception classes and usage in downloader 2025-11-27 00:52:02 -03:00
Rafael Moraes 1083957303 Raise error if present in download_item 2025-11-21 20:27:59 -03:00
fredystar200 ae6bed11af Add constant for 'CM' in constants.py (Cameroon) 2025-11-19 10:08:07 +01:00
Rafael Moraes 7da83866cf Update contributing guidelines in README 2025-11-18 15:18:44 -03:00
Rafael Moraes 273b171398 Bump version to 2.7.5 2025-11-12 12:01:27 -03:00
Rafael Moraes 2913d96b70 Filter out items without attributes in selection lists 2025-11-12 11:56:26 -03:00
Rafael Moraes a332516056 Increase retry limit in safe_gather to 10 2025-11-12 11:51:54 -03:00
Rafael Moraes c636e4be33 Mark ALAC codec as unsupported in README 2025-11-11 22:06:09 -03:00
Rafael Moraes 1841a988e2 Handle empty lyrics in AppleMusicSongInterface 2025-11-11 22:04:55 -03:00
Rafael Moraes 8cdaa127d7 Bump version to 2.7.4 2025-11-11 22:02:39 -03:00
Rafael Moraes c31a6eee8e Increase Apple Music API client timeout to 60s 2025-11-11 22:02:14 -03:00
Rafael Moraes 00d301c23d Refactor track metadata extension logic 2025-11-11 22:02:02 -03:00
Rafael Moraes f05aa579d3 Increase HTTP transport retries to 10 2025-11-11 02:14:35 -03:00
Rafael Moraes 7e642ab2f3 Refactor path prompt logic in CLI utilities 2025-11-11 01:53:59 -03:00
Rafael Moraes c34f49faae Rename song codec CLI option for consistency 2025-11-06 15:49:20 -03:00
Rafael Moraes 78c3da5b8c Remove unused imports and parameters in README example 2025-11-06 15:48:13 -03:00
Rafael Moraes 00410aeb77 Fix README table row order for template options 2025-11-06 15:45:57 -03:00
Rafael Moraes 4211ab6f8c Fix option order for no_album_folder_template 2025-11-06 15:45:48 -03:00
Rafael Moraes 599c9140db Remove debug print from load_config_file 2025-11-06 12:57:23 -03:00
Rafael Moraes 73ab79beea Move utility functions from utils.py to cli.py
Relocated the load_config_file and make_sync functions from gamdl/cli/utils.py to gamdl/cli/cli.py to improve code organization and reduce unnecessary imports in utils.py.
2025-11-06 12:54:39 -03:00
Rafael Moraes 2dfed33fe2 Refactor config param default serialization logic 2025-11-06 12:54:23 -03:00
Rafael Moraes 4eb764af17 Update PathPrompt.convert to accept str type only 2025-11-05 23:57:35 -03:00
Rafael Moraes 6cdccf1f4f Refactor Csv param type to use Enum for subtype 2025-11-05 23:42:12 -03:00
Rafael Moraes a999271715 Update README with expanded usage example 2025-11-05 08:52:26 -03:00
Rafael Moraes 633674f45e Refactor MP4 tag generation in MediaTags 2025-11-05 08:49:02 -03:00
Rafael Moraes ceeef6b352 Skip Widevine decryption for ALAC codec 2025-11-02 12:56:19 -03:00
Rafael Moraes 8aa172185a Make CDM operations async using asyncio.to_thread 2025-10-30 12:37:12 -03:00
Rafael Moraes bdbaf7ca05 Make license challenge generation asynchronous 2025-10-30 12:37:05 -03:00
Rafael Moraes a9e1e02ebb Make license parsing asynchronous in AppleMusicInterface 2025-10-30 12:35:31 -03:00
Rafael Moraes 85619a3672 Refactor MP4 tagging to use apply_mp4_tags method 2025-10-30 12:31:16 -03:00
Rafael Moraes 15c1cc45f5 Rename GamdlBinaryNotFoundError to GamdlExecutableNotFoundError 2025-10-30 00:12:19 -03:00
Rafael Moraes b86e938185 Replace MediaDownloadConfigurationError with GamdlSyncedLyricsOnlyError 2025-10-30 00:05:38 -03:00
Rafael Moraes be4596798a Rename media error classes in CLI imports and usage 2025-10-29 23:56:31 -03:00
Rafael Moraes da8e49bd68 Refactor error handling and binary checks in downloader 2025-10-29 23:56:22 -03:00
Rafael Moraes 03c3b0e788 Refactor and add custom downloader exceptions 2025-10-29 23:56:15 -03:00
Rafael Moraes 3aca011b7d Refactor AppleMusicDownloader to remove Exception from return types 2025-10-29 23:30:27 -03:00
Rafael Moraes dfa38c6736 Add error field to DownloadItem dataclass 2025-10-29 23:30:18 -03:00
Rafael Moraes 48a8c940e1 Add error handling to download item methods 2025-10-29 23:30:12 -03:00
Rafael Moraes e80c776835 Bump version 2025-10-28 12:26:36 -03:00
Rafael Moraes 36e85098e5 Improve video playlist selection by codec priority 2025-10-28 12:25:09 -03:00
Rafael Moraes 7610768723 Change download method to return DownloadItem 2025-10-28 00:45:46 -03:00
Rafael Moraes 9afe027f5d Set video resolution in stream info 2025-10-28 00:32:33 -03:00
Rafael Moraes 4c5c43844a Add width and height to StreamInfo for video resolution 2025-10-28 00:32:29 -03:00
Rafael Moraes 025c89d85a Refactor flat filter handling in downloader 2025-10-27 23:09:50 -03:00
Rafael Moraes f8d1036c37 Add flat_filter support to AppleMusicDownloader 2025-10-27 23:01:17 -03:00
Rafael Moraes 0d8e6c4626 Add playlist_metadata and flat fields to DownloadItem 2025-10-27 22:59:29 -03:00
Rafael Moraes 5aff11bcae Add playlist metadata to download items 2025-10-27 22:59:23 -03:00
Rafael Moraes b5ce18ef26 Refactor CLI to use new Apple Music interfaces 2025-10-27 22:03:45 -03:00
Rafael Moraes 70346171b1 Refactor AppleMusicDownloader to use interface 2025-10-27 22:03:38 -03:00
Rafael Moraes 4a63070489 Refactor downloader classes to inherit from base 2025-10-27 22:03:30 -03:00
Rafael Moraes cb60eee694 Refactor interfaces to inherit from AppleMusicInterface 2025-10-27 22:03:19 -03:00
Rafael Moraes 955f649779 Fix cleanup logic in AppleMusicDownloader 2025-10-27 19:52:35 -03:00
Rafael Moraes c833f24fe2 Add skip_processing checks to AppleMusicDownloader 2025-10-27 19:51:38 -03:00
Rafael Moraes bc76032532 Update configuration options table in README 2025-10-27 15:13:05 -03:00
Rafael Moraes 42f782faa5 Update help text for --wvd-path option 2025-10-27 15:12:44 -03:00
Rafael Moraes 862a150c44 Bump version to 2.7.2 2025-10-27 15:08:57 -03:00
Rafael Moraes 4cfb626d00 Remove unknown params from config file 2025-10-27 15:06:24 -03:00
Rafael Moraes fdab6481ea Rename disc folder template options to file templates 2025-10-27 15:04:07 -03:00
Rafael Moraes 9eff34390b Bump version to 2.7.1 in pyproject.toml 2025-10-25 17:56:20 -03:00
Rafael Moraes f2c1961697 Bump version to 2.7.1 2025-10-25 17:37:02 -03:00
Rafael Moraes fff227522f Fix library urls 2025-10-25 17:36:10 -03:00
Rafael Moraes b7c813571e Reduce concurrency limit in safe_gather 2025-10-25 17:32:19 -03:00
Rafael Moraes 2c91982ae0 Update music video resolution option description 2025-10-23 23:08:21 -03:00
Rafael Moraes 04f847a9bf Add project repository URL to pyproject.toml 2025-10-23 17:38:53 -03:00
23 changed files with 1015 additions and 658 deletions
+118 -77
View File
@@ -37,6 +37,7 @@ Add these tools to your system PATH for additional features:
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required for `mp4box` remux mode, music videos, and experimental codecs
- **[MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)** - Required for `mp4box` remux mode
- **[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
## 📦 Installation
@@ -110,57 +111,60 @@ The file is created automatically on first run. Command-line arguments override
### Configuration Options
| Option | Description | Default |
| ------------------------------- | -------------------------------------- | ---------------------------------------------- |
| **General Options** | | |
| `--read-urls-as-txt`, `-r` | Read URLs from text files | `false` |
| `--config-path` | Config file path | `<home>/.gamdl/config.ini` |
| `--log-level` | Logging level | `INFO` |
| `--log-file` | Log file path | - |
| `--no-exceptions` | Don't print exceptions | `false` |
| `--no-config-file`, `-n` | Don't use a config file | `false` |
| **Apple Music Options** | | |
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
| `--language`, `-l` | Metadata language | `en-US` |
| **Output Options** | | |
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
| `--temp-path` | Temporary directory path | `.` |
| `--overwrite` | Overwrite existing files | `false` |
| `--save-cover`, `-s` | Save cover as separate file | `false` |
| `--save-playlist` | Save M3U8 playlist file | `false` |
| **Download Options** | | |
| `--download-mode` | Download mode | `ytdlp` |
| `--remux-mode` | Remux mode | `ffmpeg` |
| `--cover-format` | Cover format | `jpg` |
| `--cover-size` | Cover size in pixels | `1200` |
| `--truncate` | Max filename length | - |
| **Binary Paths** | | |
| `--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` |
| `--wvd-path` | .wvd file executable path | - |
| **Template Options** | | |
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
| `--single-disc-folder-template` | Single disc template | `{track:02d} {title}` |
| `--multi-disc-folder-template` | Multi disc template | `{disc}-{track:02d} {title}` |
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
| `--no-album-file-template` | No album file template | `{title}` |
| `--playlist-file-template` | Playlist 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 | - |
| **Song Options** | | |
| `--codec-song` | 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` |
| **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 (see below) | `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` |
| `--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` |
| **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` |
### Template Variables
@@ -221,7 +225,7 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
- `aac-he-downmix` - AAC-HE 64kbps downmix
- `atmos` - Dolby Atmos 768kbps
- `ac3` - AC3 640kbps
- `alac` - ALAC up to 24-bit/192kHz
- `alac` - ALAC up to 24-bit/192kHz (unsupported)
- `ask` - Interactive experimental codec selection
### Synced Lyrics Format
@@ -250,13 +254,30 @@ 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
Use the [wrapper](https://github.com/WorldObservationLog/wrapper) and [amdecrypt](https://github.com/glomatico/amdecrypt) to download songs in ALAC and other experimental codecs without API limitations. Cookies are not required when using the wrapper.
### Prerequisites
- **[wrapper](https://github.com/WorldObservationLog/wrapper)** - Refer to the repository for installation
- **[amdecrypt](https://github.com/glomatico/amdecrypt)** - Refer to the repository for installation
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required by amdecrypt to decrypt protected files
### Setup Instructions
1. **Start the wrapper server** - Run the wrapper server
2. **Enable wrapper in Gamdl** - Use `--use-wrapper` flag or set `use_wrapper = true` in config
3. **Run Gamdl** - Download as usual with the wrapper enabled
## 🐍 Embedding
Use Gamdl as a library in your Python projects:
```python
import asyncio
from gamdl.api import AppleMusicApi
from gamdl.api import AppleMusicApi, ItunesApi
from gamdl.downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
@@ -264,39 +285,59 @@ from gamdl.downloader import (
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
)
from gamdl.interface import (
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
)
async def main():
# Initialize API
api = AppleMusicApi.from_netscape_cookies(cookies_path="cookies.txt")
await api.setup()
# Create AppleMusicApi instance (from cookies or wrapper)
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
cookies_path="cookies.txt",
)
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
)
# Initialize downloaders
base_downloader = AppleMusicBaseDownloader(apple_music_api=api)
base_downloader.setup()
# Check subscription
assert apple_music_api.active_subscription
song_downloader = AppleMusicSongDownloader(base_downloader)
song_downloader.setup()
# Set up interfaces
interface = AppleMusicInterface(apple_music_api, itunes_api)
song_interface = AppleMusicSongInterface(interface)
music_video_interface = AppleMusicMusicVideoInterface(interface)
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
music_video_downloader = AppleMusicMusicVideoDownloader(base_downloader)
music_video_downloader.setup()
# 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,
)
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(base_downloader)
uploaded_video_downloader.setup()
# Create main downloader
# Main downloader
downloader = AppleMusicDownloader(
base_downloader,
song_downloader,
music_video_downloader,
uploaded_video_downloader,
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
url_info = downloader.get_url_info(
"https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
)
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:
@@ -314,4 +355,4 @@ MIT License - see [LICENSE](LICENSE) file for details
## 🤝 Contributing
Contributions are welcome! Feel free to open issues or submit pull requests, but you may discuss major changes first on our Discord server.
Currently, I'm not interested in reviewing pull requests that change or add features. Only critical bug fixes will be considered. However, feel free to open issues for bugs or feature requests.
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.7"
__version__ = "2.8.2"
+76 -17
View File
@@ -6,7 +6,7 @@ from urllib.parse import parse_qs, urlparse
import httpx
from ..utils import raise_for_status, safe_json
from ..utils import get_response, raise_for_status, safe_json
from .constants import (
AMP_API_URL,
APPLE_MUSIC_COOKIE_DOMAIN,
@@ -22,18 +22,21 @@ class AppleMusicApi:
def __init__(
self,
storefront: str = "us",
media_user_token: str | None = None,
language: str = "en-US",
media_user_token: str | None = None,
developer_token: str | None = None,
) -> None:
self.storefront = storefront
self.media_user_token = media_user_token
self.language = language
self.media_user_token = media_user_token
self.token = developer_token
@classmethod
def from_netscape_cookies(
async def create_from_netscape_cookies(
cls,
cookies_path: str = "./cookies.txt",
language: str = "en-US",
*args,
**kwargs,
) -> "AppleMusicApi":
cookies = MozillaCookieJar(cookies_path)
cookies.load(ignore_discard=True, ignore_expires=True)
@@ -54,18 +57,55 @@ class AppleMusicApi:
"and are logged in with an active subscription."
)
return cls(
return await cls.create(
storefront=None,
media_user_token=media_user_token,
language=language,
developer_token=None,
*args,
**kwargs,
)
async def setup(self) -> None:
await self._setup_client()
await self._setup_token()
await self._setup_account_info()
@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)
async def _setup_client(self) -> None:
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": "*/*",
@@ -85,11 +125,10 @@ class AppleMusicApi:
"l": self.language,
},
follow_redirects=True,
transport=httpx.AsyncHTTPTransport(retries=3),
timeout=30.0,
timeout=60.0,
)
async def _setup_token(self) -> None:
async def _get_token(self) -> str:
response = await self.client.get(APPLE_MUSIC_HOMEPAGE_URL)
raise_for_status(response)
home_page = response.text
@@ -112,9 +151,13 @@ class AppleMusicApi:
token = token_match.group(1)
logger.debug(f"Token: {token}")
self.client.headers.update({"authorization": f"Bearer {token}"})
return token
async def _setup_account_info(self) -> None:
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
@@ -127,6 +170,22 @@ class AppleMusicApi:
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",
+1
View File
@@ -39,6 +39,7 @@ STOREFRONT_IDS = {
"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",
+7 -5
View File
@@ -16,18 +16,19 @@ class ItunesApi:
) -> None:
self.storefront = storefront
self.language = language
self.initialize()
def setup(self) -> None:
self._setup_storefront_id()
self._setup_session()
def initialize(self) -> None:
self._initialize_storefront_id()
self._initialize_client()
def _setup_storefront_id(self) -> None:
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 _setup_session(self) -> None:
def _initialize_client(self) -> None:
self.client = httpx.AsyncClient(
params={
"country": self.storefront,
@@ -36,6 +37,7 @@ class ItunesApi:
headers={
"X-Apple-Store-Front": f"{self.storefront_id} t:music31",
},
timeout=60.0,
)
async def get_lookup_result(
+151 -77
View File
@@ -1,11 +1,14 @@
import asyncio
import inspect
import logging
from functools import wraps
from pathlib import Path
import click
import colorama
from .. import __version__
from ..api import AppleMusicApi
from ..api import AppleMusicApi, ItunesApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
@@ -15,25 +18,30 @@ from ..downloader import (
CoverFormat,
DownloadItem,
DownloadMode,
MediaDownloadConfigurationError,
MediaFormatNotAvailableError,
MediaNotStreamableError,
GamdlError,
RemuxFormatMusicVideo,
RemuxMode,
)
from ..interface import (
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
MusicVideoCodec,
MusicVideoResolution,
SongCodec,
SyncedLyricsFormat,
UploadedVideoQuality,
)
from .config_file import ConfigFile
from .constants import X_NOT_IN_PATH
from .utils import Csv, CustomLoggerFormatter, PathPrompt, load_config_file, make_sync
from .utils import Csv, CustomLoggerFormatter, prompt_path
logger = logging.getLogger(__name__)
api_sig = inspect.signature(AppleMusicApi.from_netscape_cookies)
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__)
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
song_downloader_sig = inspect.signature(AppleMusicSongDownloader.__init__)
@@ -42,6 +50,40 @@ uploaded_video_downloader_sig = inspect.signature(
)
def load_config_file(
ctx: click.Context,
param: click.Parameter,
no_config_file: bool,
) -> click.Context:
if no_config_file:
return ctx
config_file = ConfigFile(ctx.params["config_path"])
config_file.cleanup_unknown_params(ctx.command.params)
config_file.add_params_default_to_config(
ctx.command.params,
)
parsed_params = config_file.parse_params_from_config(
[
param
for param in ctx.command.params
if ctx.get_parameter_source(param.name)
!= click.core.ParameterSource.COMMANDLINE
]
)
ctx.params.update(parsed_params)
return ctx
def make_sync(func):
@wraps(func)
def wrapper(*args, **kwargs):
return asyncio.run(func(*args, **kwargs))
return wrapper
@click.command()
@click.help_option("-h", "--help")
@click.version_option(__version__, "-v", "--version")
@@ -85,10 +127,16 @@ uploaded_video_downloader_sig = inspect.signature(
@click.option(
"--cookies-path",
"-c",
type=PathPrompt(is_file=True),
default=api_sig.parameters["cookies_path"].default,
type=click.Path(file_okay=True, dir_okay=False, readable=True, resolve_path=True),
default=api_from_cookies_sig.parameters["cookies_path"].default,
help="Cookies file path",
)
@click.option(
"--wrapper-account-url",
type=str,
default=api_from_wrapper_sig.parameters["wrapper_account_url"].default,
help="Wrapper account URL",
)
@click.option(
"--language",
"-l",
@@ -114,7 +162,7 @@ uploaded_video_downloader_sig = inspect.signature(
"--wvd-path",
type=click.Path(file_okay=False, dir_okay=True, writable=True, resolve_path=True),
default=base_downloader_sig.parameters["wvd_path"].default,
help=".wvd file executable path",
help=".wvd file path",
)
@click.option(
"--overwrite",
@@ -159,6 +207,24 @@ uploaded_video_downloader_sig = inspect.signature(
default=base_downloader_sig.parameters["mp4box_path"].default,
help="MP4Box executable path",
)
@click.option(
"--amdecrypt-path",
type=str,
default=base_downloader_sig.parameters["amdecrypt_path"].default,
help="amdecrypt executable path",
)
@click.option(
"--use-wrapper",
is_flag=True,
help="Use wrapper and amdecrypt for decrypting songs",
default=False,
)
@click.option(
"--wrapper-decrypt-ip",
type=str,
default=base_downloader_sig.parameters["wrapper_decrypt_ip"].default,
help="IP address and port for wrapper decryption",
)
@click.option(
"--download-mode",
type=DownloadMode,
@@ -189,24 +255,24 @@ uploaded_video_downloader_sig = inspect.signature(
default=base_downloader_sig.parameters["compilation_folder_template"].default,
help="Compilation folder template",
)
@click.option(
"--single-disc-folder-template",
type=str,
default=base_downloader_sig.parameters["single_disc_folder_template"].default,
help="Single disc template",
)
@click.option(
"--multi-disc-folder-template",
type=str,
default=base_downloader_sig.parameters["multi_disc_folder_template"].default,
help="Multi disc template",
)
@click.option(
"--no-album-folder-template",
type=str,
default=base_downloader_sig.parameters["no_album_folder_template"].default,
help="No album folder template",
)
@click.option(
"--single-disc-file-template",
type=str,
default=base_downloader_sig.parameters["single_disc_file_template"].default,
help="Single disc file template",
)
@click.option(
"--multi-disc-file-template",
type=str,
default=base_downloader_sig.parameters["multi_disc_file_template"].default,
help="Multi disc file template",
)
@click.option(
"--no-album-file-template",
type=str,
@@ -217,7 +283,7 @@ uploaded_video_downloader_sig = inspect.signature(
"--playlist-file-template",
type=str,
default=base_downloader_sig.parameters["playlist_file_template"].default,
help="Playlist template",
help="Playlist file template",
)
@click.option(
"--date-tag-template",
@@ -245,7 +311,7 @@ uploaded_video_downloader_sig = inspect.signature(
)
# DownloaderSong specific options
@click.option(
"--codec-song",
"--song-codec",
type=SongCodec,
default=song_downloader_sig.parameters["codec"].default,
help="Song codec",
@@ -311,6 +377,7 @@ async def main(
log_file: str,
no_exceptions: bool,
cookies_path: str,
wrapper_account_url: str,
language: str,
output_path: str,
temp_path: str,
@@ -322,21 +389,24 @@ async def main(
mp4decrypt_path: str,
ffmpeg_path: str,
mp4box_path: str,
amdecrypt_path: str,
use_wrapper: bool,
wrapper_decrypt_ip: str,
download_mode: DownloadMode,
remux_mode: RemuxMode,
cover_format: CoverFormat,
album_folder_template: str,
compilation_folder_template: str,
single_disc_folder_template: str,
multi_disc_folder_template: str,
no_album_folder_template: str,
single_disc_file_template: str,
multi_disc_file_template: str,
no_album_file_template: str,
playlist_file_template: str,
date_tag_template: str,
exclude_tags: list[str],
cover_size: int,
truncate: int,
codec_song: SongCodec,
song_codec: SongCodec,
synced_lyrics_format: SyncedLyricsFormat,
no_synced_lyrics: bool,
synced_lyrics_only: bool,
@@ -347,6 +417,8 @@ async def main(
*args,
**kwargs,
):
colorama.just_fix_windows_console()
root_logger = logging.getLogger(__name__.split(".")[0])
root_logger.setLevel(log_level)
root_logger.propagate = False
@@ -362,26 +434,44 @@ async def main(
logger.info(f"Starting Gamdl {__version__}")
api = AppleMusicApi.from_netscape_cookies(
cookies_path=cookies_path,
language=language,
)
await api.setup()
if use_wrapper:
apple_music_api = await AppleMusicApi.create_from_wrapper(
wrapper_account_url=wrapper_account_url,
language=language,
)
else:
cookies_path = prompt_path(cookies_path)
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
cookies_path=cookies_path,
language=language,
)
if not api.account_info["meta"]["subscription"]["active"]:
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 api.account_info["data"][0]["attributes"].get("restrictions"):
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,
)
song_interface = AppleMusicSongInterface(interface)
music_video_interface = AppleMusicMusicVideoInterface(interface)
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
base_downloader = AppleMusicBaseDownloader(
apple_music_api=api,
output_path=output_path,
temp_path=temp_path,
wvd_path=wvd_path,
@@ -392,14 +482,17 @@ async def main(
mp4decrypt_path=mp4decrypt_path,
ffmpeg_path=ffmpeg_path,
mp4box_path=mp4box_path,
amdecrypt_path=amdecrypt_path,
use_wrapper=use_wrapper,
wrapper_decrypt_ip=wrapper_decrypt_ip,
download_mode=download_mode,
remux_mode=remux_mode,
cover_format=cover_format,
album_folder_template=album_folder_template,
compilation_folder_template=compilation_folder_template,
single_disc_folder_template=single_disc_folder_template,
multi_disc_folder_template=multi_disc_folder_template,
no_album_folder_template=no_album_folder_template,
single_disc_file_template=single_disc_file_template,
multi_disc_file_template=multi_disc_file_template,
no_album_file_template=no_album_file_template,
playlist_file_template=playlist_file_template,
date_tag_template=date_tag_template,
@@ -407,36 +500,32 @@ async def main(
cover_size=cover_size,
truncate=truncate,
)
base_downloader.setup()
song_downloader = AppleMusicSongDownloader(
base_downloader,
codec=codec_song,
base_downloader=base_downloader,
interface=song_interface,
codec=song_codec,
synced_lyrics_format=synced_lyrics_format,
no_synced_lyrics=no_synced_lyrics,
synced_lyrics_only=synced_lyrics_only,
)
song_downloader.setup()
music_video_downloader = AppleMusicMusicVideoDownloader(
base_downloader,
base_downloader=base_downloader,
interface=music_video_interface,
codec_priority=music_video_codec_priority,
remux_format=music_video_remux_format,
resolution=music_video_resolution,
)
music_video_downloader.setup()
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
base_downloader,
base_downloader=base_downloader,
interface=uploaded_video_interface,
quality=uploaded_video_quality,
)
uploaded_video_downloader.setup()
downloader = AppleMusicDownloader(
base_downloader,
song_downloader,
music_video_downloader,
uploaded_video_downloader,
interface=interface,
base_downloader=base_downloader,
song_downloader=song_downloader,
music_video_downloader=music_video_downloader,
uploaded_video_downloader=uploaded_video_downloader,
)
if not synced_lyrics_only:
@@ -450,17 +539,9 @@ async def main(
logger.critical(X_NOT_IN_PATH.format("MP4Box", mp4box_path))
return
if (
not base_downloader.full_mp4decrypt_path
and codec_song
not in (
SongCodec.AAC_LEGACY,
SongCodec.AAC_HE_LEGACY,
)
or (
remux_mode == RemuxMode.MP4BOX
and not base_downloader.full_mp4decrypt_path
)
if not base_downloader.full_mp4decrypt_path and (
song_codec not in (SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY)
or remux_mode == RemuxMode.MP4BOX
):
logger.critical(X_NOT_IN_PATH.format("mp4decrypt", mp4decrypt_path))
return
@@ -472,16 +553,14 @@ async def main(
logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", nm3u8dlre_path))
return
if not base_downloader.full_mp4decrypt_path:
logger.warning(
X_NOT_IN_PATH.format("mp4decrypt", mp4decrypt_path)
+ ", music videos will not be downloaded"
)
downloader.skip_music_videos = True
if use_wrapper and not base_downloader.full_amdecrypt_path:
logger.critical(X_NOT_IN_PATH.format("amdecrypt", amdecrypt_path))
return
if not codec_song.is_legacy():
if not song_codec.is_legacy() and not use_wrapper:
logger.warning(
"You have chosen an experimental song codec. "
"You have chosen an experimental song codec"
" without enabling wrapper."
"They're not guaranteed to work due to API limitations."
)
@@ -550,12 +629,7 @@ async def main(
try:
await downloader.download(download_item)
except (
FileExistsError,
MediaNotStreamableError,
MediaFormatNotAvailableError,
MediaDownloadConfigurationError,
) as e:
except GamdlError as e:
logger.warning(
download_queue_progress + f' Skipping "{media_title}": {e}'
)
+30 -19
View File
@@ -1,11 +1,12 @@
import configparser
import typing
from enum import Enum
from pathlib import Path
import click
from click.types import BoolParamType, FuncParamType
from .constants import EXCLUDED_CONFIG_FILE_PARAMS
from .utils import Csv
class ConfigFile:
@@ -35,34 +36,29 @@ class ConfigFile:
self.config.write(config_file)
def _serialize_param_default(self, param: click.Parameter) -> str:
if not isinstance(param.default, (list, tuple)):
param_default = [param.default]
else:
param_default = param.default
if not param_default:
return ""
first = param_default[0]
if isinstance(first, Enum):
return ",".join(str(item.value) for item in param_default)
if isinstance(first, bool):
return ",".join(str(item).lower() for item in param_default)
if first is None:
if param.default is None:
return "null"
return ",".join(str(item) for item in param_default)
if isinstance(param.type, Csv):
return ",".join(item.value for item in param.default)
if isinstance(param.type, BoolParamType):
return str(param.default).lower()
if isinstance(param.type, FuncParamType):
return param.default.value
return str(param.default)
def _add_param_default_to_config(
self,
param: click.Parameter,
) -> bool:
if self.config[self.section_name].get(param.name):
if self.config.has_option(self.section_name, param.name):
return False
value = self._serialize_param_default(param)
self.config[self.section_name][param.name] = value
self.config.set(self.section_name, param.name, value)
return True
@@ -92,6 +88,21 @@ class ConfigFile:
if has_changes:
self._write_config_file()
def cleanup_unknown_params(
self,
params: list[click.Parameter],
) -> None:
param_names = {param.name for param in params}
has_changes = False
for key in list(self.config[self.section_name].keys()):
if key not in param_names:
self.config.remove_option(self.section_name, key)
has_changes = True
if has_changes:
self._write_config_file()
def parse_params_from_config(
self,
params: list[click.Parameter],
+30 -76
View File
@@ -1,29 +1,25 @@
import asyncio
import logging
import typing
from functools import wraps
from enum import Enum
from pathlib import Path
import click
from .config_file import ConfigFile
class Csv(click.ParamType):
name = "csv"
def __init__(
self,
subtype: typing.Any,
subtype: Enum,
) -> None:
self.subtype = subtype
def convert(
self,
value: str | typing.Any,
value: str,
param: click.Parameter,
ctx: click.Context,
) -> list[typing.Any]:
) -> list[Enum]:
if not isinstance(value, str):
return value
@@ -42,46 +38,6 @@ class Csv(click.ParamType):
return result
class PathPrompt(click.ParamType):
name = "path"
def __init__(self, is_file: bool = False) -> None:
self.is_file = is_file
def convert(
self,
value: str | typing.Any,
param: click.Parameter,
ctx: click.Context,
) -> str:
if not isinstance(value, str):
return value
path_validator = click.Path(
exists=True,
file_okay=self.is_file,
dir_okay=not self.is_file,
)
path_type = "file" if self.is_file else "directory"
while True:
try:
result = path_validator.convert(value, None, None)
break
except click.BadParameter as e:
value = click.prompt(
(
f'{path_type.capitalize()} "{Path(value).absolute()}" does not exist. '
f"Create the {path_type} at the specified path, "
f"type a new path or drag and drop the {path_type} here. "
"Then, press enter to continue"
),
default=value,
show_default=False,
)
value = value.strip('"')
return result
class CustomLoggerFormatter(logging.Formatter):
base_format = "[%(levelname)-8s %(asctime)s]"
format_colors = {
@@ -109,34 +65,32 @@ class CustomLoggerFormatter(logging.Formatter):
).format(record)
def load_config_file(
ctx: click.Context,
param: click.Parameter,
no_config_file: bool,
) -> click.Context:
if no_config_file:
return ctx
config_file = ConfigFile(ctx.params["config_path"])
config_file.add_params_default_to_config(
ctx.command.params,
def prompt_path(
input_path: str,
is_dir: bool = False,
) -> str:
path_validator = click.Path(
exists=True,
file_okay=not is_dir,
dir_okay=is_dir,
)
parsed_params = config_file.parse_params_from_config(
[
param
for param in ctx.command.params
if ctx.get_parameter_source(param.name)
!= click.core.ParameterSource.COMMANDLINE
]
)
ctx.params.update(parsed_params)
path_type = "directory" if is_dir else "file"
return ctx
while True:
try:
result_path = path_validator.convert(input_path, None, None)
break
except click.BadParameter as e:
input_path = click.prompt(
(
f'{path_type.capitalize()} "{Path(input_path).absolute()}" does not exist. '
f"Create the {path_type} at the specified path, "
f"type a new path or drag and drop the {path_type} here. "
"Then, press enter to continue"
),
default=input_path,
show_default=False,
)
input_path = input_path.strip('"')
def make_sync(func):
@wraps(func)
def wrapper(*args, **kwargs):
return asyncio.run(func(*args, **kwargs))
return wrapper
return result_path
+157 -82
View File
@@ -1,9 +1,11 @@
import asyncio
import typing
from pathlib import Path
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from ..interface import AppleMusicInterface
from ..utils import safe_gather
from .constants import (
ALBUM_MEDIA_TYPE,
@@ -18,10 +20,13 @@ from .downloader_base import AppleMusicBaseDownloader
from .downloader_music_video import AppleMusicMusicVideoDownloader
from .downloader_song import AppleMusicSongDownloader
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
from .enums import DownloadMode, RemuxMode
from .exceptions import (
MediaFormatNotAvailableError,
MediaNotStreamableError,
MediaDownloadConfigurationError,
ExecutableNotFound,
FormatNotAvailable,
MediaFileExists,
NotStreamable,
SyncedLyricsOnly,
)
from .types import DownloadItem, UrlInfo
@@ -29,25 +34,62 @@ from .types import DownloadItem, UrlInfo
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,
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.skip_processing = skip_processing
self.flat_filter = flat_filter
async def get_single_download_item(
self,
media_metadata: dict,
playlist_metadata: dict = None,
) -> 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 flat_filter_result:
return DownloadItem(
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
flat_filter_result=flat_filter_result,
)
return await self.get_single_download_item_no_filter(
media_metadata,
playlist_metadata,
)
async def get_single_download_item_no_filter(
self,
media_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
download_item = None
if not self.base_downloader.is_media_streamable(
media_metadata,
):
return DownloadItem(
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
error=NotStreamable(media_metadata["id"]),
)
if media_metadata["type"] in SONG_MEDIA_TYPE:
download_item = await self.song_downloader.get_download_item(
media_metadata,
@@ -70,28 +112,23 @@ class AppleMusicDownloader:
async def get_collection_download_items(
self,
collection_metadata: dict,
) -> list[DownloadItem | Exception]:
collection_metadata["relationships"]["tracks"]["data"].extend(
[
extended_data
async for extended_data in self.base_downloader.apple_music_api.extend_api_data(
collection_metadata["relationships"]["tracks"],
)
]
)
) -> 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 = [
asyncio.create_task(
self.get_single_download_item(
media_metadata,
(
collection_metadata
if collection_metadata["type"] in PLAYLIST_MEDIA_TYPE
else None
),
)
self.get_single_download_item(
media_metadata,
(
collection_metadata
if collection_metadata["type"] in PLAYLIST_MEDIA_TYPE
else None
),
)
for media_metadata in collection_metadata["relationships"]["tracks"]["data"]
for media_metadata in tracks_metadata
]
download_items = await safe_gather(*tasks)
@@ -100,12 +137,12 @@ class AppleMusicDownloader:
async def get_artist_download_items(
self,
artist_metadata: dict,
) -> list[DownloadItem | Exception]:
) -> list[DownloadItem]:
for relationship in artist_metadata["relationships"].keys():
artist_metadata["relationships"][relationship]["data"].extend(
[
extended_data
async for extended_data in self.base_downloader.apple_music_api.extend_api_data(
async for extended_data in self.interface.apple_music_api.extend_api_data(
artist_metadata["relationships"][relationship],
)
]
@@ -141,7 +178,7 @@ class AppleMusicDownloader:
async def get_artist_albums_download_items(
self,
albums_metadata: list[dict],
) -> list[DownloadItem | Exception]:
) -> list[DownloadItem]:
choices = [
Choice(
name=" | ".join(
@@ -155,6 +192,7 @@ class AppleMusicDownloader:
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)",
@@ -165,17 +203,13 @@ class AppleMusicDownloader:
download_items = []
album_tasks = [
asyncio.create_task(
self.base_downloader.apple_music_api.get_album(album_metadata["id"])
)
self.interface.apple_music_api.get_album(album_metadata["id"])
for album_metadata in selected
]
album_responses = await safe_gather(*album_tasks)
track_tasks = [
asyncio.create_task(
self.get_collection_download_items(album_response["data"][0])
)
self.get_collection_download_items(album_response["data"][0])
for album_response in album_responses
]
track_results = await safe_gather(*track_tasks)
@@ -188,7 +222,7 @@ class AppleMusicDownloader:
async def get_artist_music_videos_download_items(
self,
music_videos_metadata: list[dict],
) -> list[DownloadItem | Exception]:
) -> list[DownloadItem]:
choices = [
Choice(
name=" | ".join(
@@ -203,6 +237,7 @@ class AppleMusicDownloader:
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)",
@@ -211,10 +246,8 @@ class AppleMusicDownloader:
).execute_async()
music_video_tasks = [
asyncio.create_task(
self.get_single_download_item(
music_video_metadata,
)
self.get_single_download_item(
music_video_metadata,
)
for music_video_metadata in selected
]
@@ -238,9 +271,9 @@ class AppleMusicDownloader:
async def get_download_queue(
self,
url_info: UrlInfo,
) -> list[DownloadItem | Exception] | None:
) -> list[DownloadItem] | None:
return await self._get_download_queue(
"song" if url_info.sub_id else url_info.type,
"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,
)
@@ -250,11 +283,11 @@ class AppleMusicDownloader:
url_type: str,
id: str,
is_library: bool,
) -> list[DownloadItem | Exception] | None:
) -> list[DownloadItem] | None:
download_items = []
if url_type in ARTIST_MEDIA_TYPE:
artist_response = await self.base_downloader.apple_music_api.get_artist(
artist_response = await self.interface.apple_music_api.get_artist(
id,
)
@@ -266,7 +299,7 @@ class AppleMusicDownloader:
)
if url_type in SONG_MEDIA_TYPE:
song_respose = await self.base_downloader.apple_music_api.get_song(id)
song_respose = await self.interface.apple_music_api.get_song(id)
if song_respose is None:
return None
@@ -277,13 +310,11 @@ class AppleMusicDownloader:
if url_type in ALBUM_MEDIA_TYPE:
if is_library:
album_response = (
await self.base_downloader.apple_music_api.get_library_album(id)
)
else:
album_response = await self.base_downloader.apple_music_api.get_album(
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
@@ -295,11 +326,11 @@ class AppleMusicDownloader:
if url_type in PLAYLIST_MEDIA_TYPE:
if is_library:
playlist_response = (
await self.base_downloader.apple_music_api.get_library_playlist(id)
await self.interface.apple_music_api.get_library_playlist(id)
)
else:
playlist_response = (
await self.base_downloader.apple_music_api.get_playlist(id)
playlist_response = await self.interface.apple_music_api.get_playlist(
id
)
if playlist_response is None:
@@ -310,8 +341,8 @@ class AppleMusicDownloader:
)
if url_type in MUSIC_VIDEO_MEDIA_TYPE:
music_video_response = (
await self.base_downloader.apple_music_api.get_music_video(id)
music_video_response = await self.interface.apple_music_api.get_music_video(
id
)
if music_video_response is None:
@@ -322,9 +353,7 @@ class AppleMusicDownloader:
)
if url_type in UPLOADED_VIDEO_MEDIA_TYPE:
uploaded_video = (
await self.base_downloader.apple_music_api.get_uploaded_video(id)
)
uploaded_video = await self.interface.apple_music_api.get_uploaded_video(id)
if uploaded_video is None:
return None
@@ -335,16 +364,27 @@ class AppleMusicDownloader:
return download_items
async def download(self, download_item: DownloadItem | Exception) -> None:
async def download(
self,
download_item: DownloadItem,
) -> DownloadItem:
try:
if isinstance(download_item, Exception):
raise download_item
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):
if isinstance(download_item, DownloadItem) and not self.skip_processing:
self.base_downloader.cleanup_temp(download_item.random_uuid)
async def _download(
@@ -354,40 +394,69 @@ class AppleMusicDownloader:
if (
self.song_downloader.synced_lyrics_only
and download_item.media_metadata["type"] not in SONG_MEDIA_TYPE
) or (
self.skip_music_videos
and download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
):
raise MediaDownloadConfigurationError(download_item.media_metadata["id"])
raise SyncedLyricsOnly()
if self.song_downloader.synced_lyrics_only:
return
if download_item.media_metadata["type"] in {
*SONG_MEDIA_TYPE,
*MUSIC_VIDEO_MEDIA_TYPE,
} and (
not download_item.stream_info
or not download_item.stream_info.audio_track.widevine_pssh
):
raise MediaFormatNotAvailableError(
download_item.media_metadata["id"],
)
if (
Path(download_item.final_path).exists()
and not self.base_downloader.overwrite
):
raise FileExistsError(
f'Media file already exists at "{download_item.final_path}"'
)
raise MediaFileExists(download_item.final_path)
if not self.base_downloader.is_media_streamable(
download_item.media_metadata,
):
raise MediaNotStreamableError(
download_item.media_metadata["id"],
)
if download_item.media_metadata["type"] in {
*SONG_MEDIA_TYPE,
*MUSIC_VIDEO_MEDIA_TYPE,
}:
if (
self.base_downloader.remux_mode == RemuxMode.FFMPEG
and not self.base_downloader.full_ffmpeg_path
):
raise ExecutableNotFound("ffmpeg")
if (
self.base_downloader.remux_mode == RemuxMode.MP4BOX
and not self.base_downloader.full_mp4box_path
):
raise ExecutableNotFound("MP4Box")
if (
self.song_downloader.use_wrapper
or (
download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
or self.base_downloader.remux_mode == RemuxMode.MP4BOX
)
) and not self.base_downloader.full_mp4decrypt_path:
raise ExecutableNotFound("mp4decrypt")
if (
self.song_downloader.use_wrapper
and not self.base_downloader.full_amdecrypt_path
):
raise ExecutableNotFound("amdecrypt")
if (
self.base_downloader.download_mode == DownloadMode.NM3U8DLRE
and not self.base_downloader.full_nm3u8dlre_path
):
raise ExecutableNotFound("N_m3u8DL-RE")
if (
not download_item.stream_info
or not download_item.stream_info.audio_track
or not download_item.stream_info.audio_track.stream_url
or (
(
not download_item.decryption_key
or not download_item.decryption_key.audio_track
or not download_item.decryption_key.audio_track.key
)
and not self.base_downloader.use_wrapper
)
):
raise FormatNotAvailable(download_item.media_metadata["id"])
if download_item.media_metadata["type"] in SONG_MEDIA_TYPE:
await self.song_downloader.download(download_item)
@@ -402,6 +471,9 @@ class AppleMusicDownloader:
self,
download_item: DownloadItem,
) -> None:
if self.skip_processing:
return
if download_item.cover_path and self.base_downloader.save_cover:
cover_url = self.base_downloader.get_cover_url(
download_item.cover_url_template,
@@ -441,6 +513,9 @@ class AppleMusicDownloader:
self,
download_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,
+55 -38
View File
@@ -12,11 +12,8 @@ from PIL import Image
from pywidevine import Cdm, Device
from yt_dlp import YoutubeDL
from ..api.apple_music_api import AppleMusicApi
from ..api.itunes_api import ItunesApi
from ..interface.interface import AppleMusicInterface
from ..interface.types import MediaTags, PlaylistTags
from ..utils import async_subprocess, raise_for_status
from ..utils import async_subprocess, get_response
from .constants import (
ILLEGAL_CHAR_REPLACEMENT,
ILLEGAL_CHARS_RE,
@@ -30,7 +27,6 @@ from .hardcoded_wvd import HARDCODED_WVD
class AppleMusicBaseDownloader:
def __init__(
self,
apple_music_api: AppleMusicApi,
output_path: str = "./Apple Music",
temp_path: str = ".",
wvd_path: str = None,
@@ -41,14 +37,17 @@ class AppleMusicBaseDownloader:
mp4decrypt_path: str = "mp4decrypt",
ffmpeg_path: str = "ffmpeg",
mp4box_path: str = "MP4Box",
amdecrypt_path: str = "amdecrypt",
use_wrapper: bool = False,
wrapper_decrypt_ip: str = "127.0.0.1:10020",
download_mode: DownloadMode = DownloadMode.YTDLP,
remux_mode: RemuxMode = RemuxMode.FFMPEG,
cover_format: CoverFormat = CoverFormat.JPG,
album_folder_template: str = "{album_artist}/{album}",
compilation_folder_template: str = "Compilations/{album}",
single_disc_folder_template: str = "{track:02d} {title}",
multi_disc_folder_template: str = "{disc}-{track:02d} {title}",
no_album_folder_template: str = "{artist}/Unknown Album",
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}",
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
@@ -56,9 +55,7 @@ class AppleMusicBaseDownloader:
cover_size: int = 1200,
truncate: int = None,
silent: bool = False,
skip_processing: bool = False,
):
self.apple_music_api = apple_music_api
self.output_path = output_path
self.temp_path = temp_path
self.wvd_path = wvd_path
@@ -69,14 +66,17 @@ class AppleMusicBaseDownloader:
self.mp4decrypt_path = mp4decrypt_path
self.ffmpeg_path = ffmpeg_path
self.mp4box_path = mp4box_path
self.amdecrypt_path = amdecrypt_path
self.use_wrapper = use_wrapper
self.wrapper_decrypt_ip = wrapper_decrypt_ip
self.download_mode = download_mode
self.remux_mode = remux_mode
self.cover_format = cover_format
self.album_folder_template = album_folder_template
self.compilation_folder_template = compilation_folder_template
self.single_disc_folder_template = single_disc_folder_template
self.multi_disc_folder_template = multi_disc_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.no_album_file_template = no_album_file_template
self.playlist_file_template = playlist_file_template
self.date_tag_template = date_tag_template
@@ -84,34 +84,26 @@ class AppleMusicBaseDownloader:
self.cover_size = cover_size
self.truncate = truncate
self.silent = silent
self.skip_processing = skip_processing
self.initialize()
def setup(self):
self._setup_binary_paths()
self._setup_cdm()
self._setup_interface()
def initialize(self):
self._initialize_binary_paths()
self._initialize_cdm()
def _setup_binary_paths(self):
def _initialize_binary_paths(self):
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 _setup_cdm(self):
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 _setup_interface(self):
self.itunes_api = ItunesApi(
self.apple_music_api.storefront,
self.apple_music_api.language,
)
self.itunes_api.setup()
self.interface = AppleMusicInterface(self.apple_music_api, self.itunes_api)
def get_random_uuid(self) -> str:
return uuid.uuid4().hex[:8]
@@ -171,13 +163,10 @@ class AppleMusicBaseDownloader:
@alru_cache()
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
async with httpx.AsyncClient() as client:
response = await client.get(cover_url)
raise_for_status(response, {200, 404})
if response.status_code == 200:
return response.content
return None
response = await get_response(cover_url, {200, 404})
if response.status_code == 200:
return response.content
return None
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
dirty_string = re.sub(
@@ -207,9 +196,9 @@ class AppleMusicBaseDownloader:
else self.album_folder_template.split("/")
)
template_file = (
self.multi_disc_folder_template.split("/")
self.multi_disc_file_template.split("/")
if tags.disc_total > 1
else self.single_disc_folder_template.split("/")
else self.single_disc_file_template.split("/")
)
else:
template_folder = self.no_album_folder_template.split("/")
@@ -343,15 +332,43 @@ class AppleMusicBaseDownloader:
}
)
mp4_tags = filtered_tags.as_mp4_tags(self.date_tag_template)
cover_url = self.get_cover_url(cover_url_template)
cover_bytes = await self.get_cover_bytes(cover_url)
skip_tagging = "all" in exclude_tags
await asyncio.to_thread(
self.apply_mp4_tags,
media_path,
mp4_tags,
cover_bytes,
skip_tagging,
)
def apply_mp4_tags(
self,
media_path: Path,
tags: dict,
cover_bytes: bytes | None,
skip_tagging: bool,
):
mp4 = MP4(media_path)
mp4.clear()
if not skip_tagging:
if "cover" not in exclude_tags and self.cover_format != CoverFormat.RAW:
await self._apply_cover(mp4, cover_url_template)
mp4.update(mp4_tags)
if cover_bytes is not None:
mp4["covr"] = [
MP4Cover(
data=cover_bytes,
imageformat=(
MP4Cover.FORMAT_JPEG
if self.cover_format == CoverFormat.JPG
else MP4Cover.FORMAT_PNG
),
)
]
mp4.update(tags)
mp4.save()
+52 -44
View File
@@ -9,10 +9,11 @@ from .enums import RemuxFormatMusicVideo, RemuxMode
from .types import DownloadItem
class AppleMusicMusicVideoDownloader:
class AppleMusicMusicVideoDownloader(AppleMusicBaseDownloader):
def __init__(
self,
downloader: AppleMusicBaseDownloader,
base_downloader: AppleMusicBaseDownloader,
interface: AppleMusicMusicVideoInterface,
codec_priority: list[MusicVideoCodec] = [
MusicVideoCodec.H264,
MusicVideoCodec.H265,
@@ -20,19 +21,12 @@ class AppleMusicMusicVideoDownloader:
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
):
self.downloader = downloader
self.__dict__.update(base_downloader.__dict__)
self.interface = interface
self.codec_priority = codec_priority
self.remux_format = remux_format
self.resolution = resolution
def setup(self):
self._setup_interface()
def _setup_interface(self):
self.music_video_interface = AppleMusicMusicVideoInterface(
self.downloader.interface,
)
async def remux_mp4box(
self,
input_path_video: str,
@@ -40,7 +34,7 @@ class AppleMusicMusicVideoDownloader:
output_path: str,
):
await async_subprocess(
self.downloader.full_mp4box_path,
self.full_mp4box_path,
"-quiet",
"-add",
input_path_audio,
@@ -51,7 +45,7 @@ class AppleMusicMusicVideoDownloader:
"-keep-utc",
"-new",
output_path,
silent=self.downloader.silent,
silent=self.silent,
)
async def remux_ffmpeg(
@@ -70,7 +64,7 @@ class AppleMusicMusicVideoDownloader:
key = []
await async_subprocess(
self.downloader.full_ffmpeg_path,
self.full_ffmpeg_path,
"-loglevel",
"error",
"-y",
@@ -86,7 +80,7 @@ class AppleMusicMusicVideoDownloader:
"-movflags",
"+faststart",
output_path,
silent=self.downloader.silent,
silent=self.silent,
)
async def decrypt_mp4decrypt(
@@ -96,12 +90,12 @@ class AppleMusicMusicVideoDownloader:
decryption_key: str,
):
await async_subprocess(
self.downloader.full_mp4decrypt_path,
self.full_mp4decrypt_path,
"--key",
f"1:{decryption_key}",
input_path,
output_path,
silent=self.downloader.silent,
silent=self.silent,
)
async def stage(
@@ -124,7 +118,7 @@ class AppleMusicMusicVideoDownloader:
decryption_key.audio_track.key,
)
if self.downloader.remux_mode == RemuxMode.MP4BOX:
if self.remux_mode == RemuxMode.MP4BOX:
await self.remux_mp4box(
decrypted_path_video,
decrypted_path_audio,
@@ -148,50 +142,64 @@ class AppleMusicMusicVideoDownloader:
self,
music_video_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
try:
return await self._get_download_item(
music_video_metadata,
playlist_metadata,
)
except Exception as e:
return DownloadItem(
media_metadata=music_video_metadata,
playlist_metadata=playlist_metadata,
error=e,
)
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.downloader.interface.get_media_id_of_library_media(
music_video_id = self.interface.get_media_id_of_library_media(
music_video_metadata,
)
itunes_page_metadata = (
await self.music_video_interface.get_itunes_page_metadata(
music_video_metadata,
)
itunes_page_metadata = await self.interface.get_itunes_page_metadata(
music_video_metadata,
)
download_item.media_tags = await self.music_video_interface.get_tags(
download_item.media_tags = await self.interface.get_tags(
music_video_metadata,
itunes_page_metadata,
)
if playlist_metadata:
download_item.playlist_tags = self.downloader.get_playlist_tags(
download_item.playlist_tags = self.get_playlist_tags(
playlist_metadata,
music_video_metadata,
)
download_item.playlist_file_path = self.downloader.get_playlist_file_path(
download_item.playlist_file_path = self.get_playlist_file_path(
download_item.playlist_tags,
)
stream_info = await self.music_video_interface.get_stream_info(
download_item.stream_info = await self.interface.get_stream_info(
music_video_metadata,
itunes_page_metadata,
self.codec_priority,
self.resolution,
)
download_item.stream_info = stream_info
decryption_key = await self.music_video_interface.get_decryption_key(
stream_info,
self.downloader.cdm,
download_item.decryption_key = await self.interface.get_decryption_key(
download_item.stream_info,
self.cdm,
)
download_item.decryption_key = decryption_key
download_item.random_uuid = self.downloader.get_random_uuid()
download_item.staged_path = self.downloader.get_temp_path(
download_item.random_uuid = self.get_random_uuid()
download_item.staged_path = self.get_temp_path(
music_video_id,
download_item.random_uuid,
"staged",
@@ -204,16 +212,16 @@ class AppleMusicMusicVideoDownloader:
)
),
)
download_item.final_path = self.downloader.get_final_path(
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.downloader.get_cover_url_template(
download_item.cover_url_template = self.get_cover_url_template(
music_video_metadata,
)
cover_file_extension = await self.downloader.get_cover_file_extension(
cover_file_extension = await self.get_cover_file_extension(
download_item.cover_url_template,
)
if cover_file_extension:
@@ -228,35 +236,35 @@ class AppleMusicMusicVideoDownloader:
self,
download_item: DownloadItem,
) -> None:
encrypted_path_video = self.downloader.get_temp_path(
encrypted_path_video = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"encrypted_video",
".mp4",
)
encrypted_path_audio = self.downloader.get_temp_path(
encrypted_path_audio = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"encrypted_audio",
".m4a",
)
await self.downloader.download_stream(
await self.download_stream(
download_item.stream_info.video_track.stream_url,
encrypted_path_video,
)
await self.downloader.download_stream(
await self.download_stream(
download_item.stream_info.audio_track.stream_url,
encrypted_path_audio,
)
decrypted_path_video = self.downloader.get_temp_path(
decrypted_path_video = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"decrypted_video",
".mp4",
)
decrypted_path_audio = self.downloader.get_temp_path(
decrypted_path_audio = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"decrypted_audio",
@@ -272,7 +280,7 @@ class AppleMusicMusicVideoDownloader:
download_item.decryption_key,
)
await self.downloader.apply_tags(
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
download_item.cover_url_template,
+95 -54
View File
@@ -10,59 +10,73 @@ from .enums import RemuxMode
from .types import DownloadItem
class AppleMusicSongDownloader:
class AppleMusicSongDownloader(AppleMusicBaseDownloader):
def __init__(
self,
downloader: AppleMusicBaseDownloader,
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,
):
self.downloader = downloader
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
def setup(self):
self._setup_interface()
def _setup_interface(self):
self.song_interface = AppleMusicSongInterface(self.downloader.interface)
async def get_download_item(
self,
song_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
try:
return await self._get_download_item(
song_metadata,
playlist_metadata,
)
except Exception as e:
return DownloadItem(
media_metadata=song_metadata,
playlist_metadata=playlist_metadata,
error=e,
)
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.downloader.interface.get_media_id_of_library_media(song_metadata)
song_id = self.interface.get_media_id_of_library_media(song_metadata)
download_item.lyrics = await self.song_interface.get_lyrics(
download_item.lyrics = await self.interface.get_lyrics(
song_metadata,
self.synced_lyrics_format,
)
webplayback = await self.downloader.apple_music_api.get_webplayback(song_id)
download_item.media_tags = self.song_interface.get_tags(
webplayback = await self.interface.apple_music_api.get_webplayback(song_id)
download_item.media_tags = self.interface.get_tags(
webplayback,
download_item.lyrics.unsynced if download_item.lyrics else None,
)
if playlist_metadata:
download_item.playlist_tags = self.downloader.get_playlist_tags(
download_item.playlist_tags = self.get_playlist_tags(
playlist_metadata,
song_metadata,
)
download_item.playlist_file_path = self.downloader.get_playlist_file_path(
download_item.playlist_file_path = self.get_playlist_file_path(
download_item.playlist_tags,
)
download_item.final_path = self.downloader.get_final_path(
download_item.final_path = self.get_final_path(
download_item.media_tags,
".m4a",
download_item.playlist_tags,
@@ -75,48 +89,47 @@ class AppleMusicSongDownloader:
return download_item
if self.codec.is_legacy():
download_item.stream_info = (
await self.song_interface.get_stream_info_legacy(
webplayback,
self.codec,
)
download_item.stream_info = await self.interface.get_stream_info_legacy(
webplayback,
self.codec,
)
download_item.decryption_key = (
await self.song_interface.get_decryption_key_legacy(
await self.interface.get_decryption_key_legacy(
download_item.stream_info,
self.downloader.cdm,
self.cdm,
)
)
else:
download_item.stream_info = await self.song_interface.get_stream_info(
download_item.stream_info = await self.interface.get_stream_info(
song_metadata,
self.codec,
)
if (
download_item.stream_info
not self.use_wrapper
and download_item.stream_info
and download_item.stream_info.audio_track.widevine_pssh
):
download_item.decryption_key = (
await self.song_interface.get_decryption_key(
download_item.stream_info,
self.downloader.cdm,
)
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.downloader.get_cover_url_template(
song_metadata
)
download_item.cover_url_template = self.get_cover_url_template(song_metadata)
download_item.random_uuid = self.downloader.get_random_uuid()
download_item.staged_path = self.downloader.get_temp_path(
song_id,
download_item.random_uuid,
"staged",
"." + download_item.stream_info.file_format.value,
)
cover_file_extension = await self.downloader.get_cover_file_extension(
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.get_cover_file_extension(
download_item.cover_url_template,
)
if cover_file_extension:
@@ -143,7 +156,7 @@ class AppleMusicSongDownloader:
async def remux_mp4box(self, input_path: str, output_path: str):
await async_subprocess(
self.downloader.full_mp4box_path,
self.full_mp4box_path,
"-quiet",
"-add",
input_path,
@@ -152,7 +165,7 @@ class AppleMusicSongDownloader:
"-keep-utc",
"-new",
output_path,
silent=self.downloader.silent,
silent=self.silent,
)
async def remux_ffmpeg(
@@ -170,7 +183,7 @@ class AppleMusicSongDownloader:
key = []
await async_subprocess(
self.downloader.full_ffmpeg_path,
self.full_ffmpeg_path,
"-loglevel",
"error",
"-y",
@@ -182,7 +195,7 @@ class AppleMusicSongDownloader:
"-movflags",
"+faststart",
output_path,
silent=self.downloader.silent,
silent=self.silent,
)
async def decrypt_mp4decrypt(
@@ -207,11 +220,28 @@ class AppleMusicSongDownloader:
]
await async_subprocess(
self.downloader.full_mp4decrypt_path,
self.full_mp4decrypt_path,
*keys,
input_path,
output_path,
silent=self.downloader.silent,
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(
@@ -221,21 +251,23 @@ class AppleMusicSongDownloader:
staged_path: str,
decryption_key: DecryptionKeyAv,
codec: SongCodec,
media_id: str,
fairplay_key: str,
):
if codec.is_legacy() and self.downloader.remux_mode == RemuxMode.FFMPEG:
if codec.is_legacy() and self.remux_mode == RemuxMode.FFMPEG:
await self.remux_ffmpeg(
encrypted_path,
staged_path,
decryption_key.audio_track.key,
)
else:
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.downloader.remux_mode == RemuxMode.FFMPEG:
if self.remux_mode == RemuxMode.FFMPEG:
await self.remux_ffmpeg(
decrypted_path,
staged_path,
@@ -245,6 +277,13 @@ class AppleMusicSongDownloader:
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))
@@ -271,18 +310,18 @@ class AppleMusicSongDownloader:
if self.synced_lyrics_only:
return
encrypted_path = self.downloader.get_temp_path(
encrypted_path = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"encrypted",
".m4a",
)
await self.downloader.download_stream(
await self.download_stream(
download_item.stream_info.audio_track.stream_url,
encrypted_path,
)
decrypted_path = self.downloader.get_temp_path(
decrypted_path = self.get_temp_path(
download_item.media_metadata["id"],
download_item.random_uuid,
"decrypted",
@@ -294,9 +333,11 @@ class AppleMusicSongDownloader:
download_item.staged_path,
download_item.decryption_key,
self.codec,
download_item.media_metadata["id"],
download_item.stream_info.audio_track.fairplay_key,
)
await self.downloader.apply_tags(
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
download_item.cover_url_template,
+28 -20
View File
@@ -6,60 +6,68 @@ from .downloader_base import AppleMusicBaseDownloader
from .types import DownloadItem
class AppleMusicUploadedVideoDownloader:
class AppleMusicUploadedVideoDownloader(AppleMusicBaseDownloader):
def __init__(
self,
downloader: AppleMusicBaseDownloader,
base_downloader: AppleMusicBaseDownloader,
interface: AppleMusicUploadedVideoInterface,
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
):
self.downloader = downloader
self.__dict__.update(base_downloader.__dict__)
self.interface = interface
self.quality = quality
def setup(self):
self._setup_interface()
def _setup_interface(self):
self.uploaded_video_interface = AppleMusicUploadedVideoInterface(
self.downloader.interface,
)
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.uploaded_video_interface.get_tags(
download_item.media_tags = self.interface.get_tags(
uploaded_video_metadata,
)
download_item.stream_info = await self.uploaded_video_interface.get_stream_info(
download_item.stream_info = await self.interface.get_stream_info(
uploaded_video_metadata,
self.quality,
)
download_item.random_uuid = self.downloader.get_random_uuid()
download_item.staged_path = self.downloader.get_temp_path(
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.downloader.get_final_path(
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.downloader.get_cover_url_template(
download_item.cover_url_template = self.get_cover_url_template(
uploaded_video_metadata,
)
cover_file_extension = await self.downloader.get_cover_file_extension(
cover_file_extension = await self.get_cover_file_extension(
download_item.cover_url_template,
)
if cover_file_extension:
@@ -74,11 +82,11 @@ class AppleMusicUploadedVideoDownloader:
self,
download_item: DownloadItem,
) -> None:
await self.downloader.download_ytdlp(
await self.download_ytdlp(
download_item.stream_info.video_track.stream_url,
download_item.staged_path,
)
await self.downloader.apply_tags(
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
download_item.cover_url_template,
+21 -13
View File
@@ -1,19 +1,27 @@
class MediaNotStreamableError(Exception):
class GamdlError(Exception):
pass
class MediaFileExists(GamdlError):
def __init__(self, media_path: str):
super().__init__(f"Media file already exists at path: {media_path}")
class NotStreamable(GamdlError):
def __init__(self, media_id: str):
super().__init__(
f'Media with ID "{media_id}" is not streamable'.format(media_id=media_id)
)
super().__init__(f"Media ID is not streamable: {media_id}")
class MediaFormatNotAvailableError(Exception):
class FormatNotAvailable(GamdlError):
def __init__(self, media_id: str):
super().__init__(
f'Media with ID "{media_id}" is not available in the requested format'
)
super().__init__(f"Requested format is not available for media ID: {media_id}")
class MediaDownloadConfigurationError(Exception):
def __init__(self, media_id: str):
super().__init__(
f'Media with ID "{media_id}" is not downloadable with the current configuration'
)
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")
+4
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from typing import Any
from ..interface.types import (
DecryptionKeyAv,
@@ -12,6 +13,7 @@ from ..interface.types import (
@dataclass
class DownloadItem:
media_metadata: dict = None
playlist_metadata: dict = None
random_uuid: str = None
lyrics: Lyrics = None
media_tags: MediaTags = None
@@ -24,6 +26,8 @@ class DownloadItem:
playlist_file_path: str = None
synced_lyrics_path: str = None
cover_path: str = None
flat_filter_result: Any = None
error: Exception = None
@dataclass
+5 -2
View File
@@ -1,3 +1,4 @@
import asyncio
import base64
import datetime
import logging
@@ -41,7 +42,9 @@ class AppleMusicInterface:
pssh_obj = PSSH(track_uri.split(",")[-1])
challenge = base64.b64encode(
cdm.get_license_challenge(cdm_session, pssh_obj)
await asyncio.to_thread(
cdm.get_license_challenge, cdm_session, pssh_obj
)
).decode()
license = await self.apple_music_api.get_license_exchange(
track_id,
@@ -49,7 +52,7 @@ class AppleMusicInterface:
challenge,
)
cdm.parse_license(cdm_session, license["license"])
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"
)
+77 -48
View File
@@ -7,7 +7,7 @@ from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from pywidevine import Cdm
from ..utils import get_response_text
from ..utils import get_response
from .constants import MP4_FORMAT_CODECS
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
from .interface import AppleMusicInterface
@@ -16,19 +16,16 @@ from .types import DecryptionKeyAv, MediaFileFormat, MediaTags, StreamInfo, Stre
logger = logging.getLogger(__name__)
class AppleMusicMusicVideoInterface:
def __init__(
self,
interface: AppleMusicInterface,
):
self.interface = interface
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.interface.itunes_api.get_itunes_page(
itunes_page = await self.itunes_api.get_itunes_page(
"music-video",
alt_id,
)
@@ -69,7 +66,7 @@ class AppleMusicMusicVideoInterface:
self,
collection_id: int,
) -> dict | None:
album_response = await self.interface.apple_music_api.get_album(collection_id)
album_response = await self.apple_music_api.get_album(collection_id)
if not album_response:
return None
return album_response["data"][0]
@@ -80,9 +77,7 @@ class AppleMusicMusicVideoInterface:
itunes_page_metadata: dict,
) -> MediaTags:
alt_id = self.get_alt_id(metadata)
lookup_metadata = (await self.interface.itunes_api.get_lookup_result(alt_id))[
"results"
]
lookup_metadata = (await self.itunes_api.get_lookup_result(alt_id))["results"]
explicitness = lookup_metadata[0]["trackExplicitness"]
if explicitness == "notExplicit":
@@ -96,11 +91,11 @@ class AppleMusicMusicVideoInterface:
artist=lookup_metadata[0]["artistName"],
artist_id=int(lookup_metadata[0]["artistId"]),
copyright=itunes_page_metadata.get("copyright"),
date=self.interface.parse_date(lookup_metadata[0]["releaseDate"]),
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.interface.itunes_api.storefront_id.split("-")[0]),
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
title=lookup_metadata[0]["trackCensoredName"],
title_id=int(metadata["id"]),
rating=rating,
@@ -137,14 +132,16 @@ class AppleMusicMusicVideoInterface:
itunes_page_metadata,
)
else:
webplayback_response = await self.interface.apple_music_api.get_webplayback(
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_text(m3u8_master_url))
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,
@@ -180,31 +177,37 @@ class AppleMusicMusicVideoInterface:
def get_video_playlist_from_resolution(
self,
video_playlists: list[m3u8.Playlist],
codec: MusicVideoCodec,
codec_priority: list[MusicVideoCodec],
resolution: MusicVideoResolution,
) -> m3u8.Playlist | None:
playlists_filtered = [
playlist
for playlist in video_playlists
if playlist.stream_info.codecs.startswith(codec.fourcc())
]
if not playlists_filtered:
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(playlist: m3u8.Playlist) -> tuple[int, int, int, int]:
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]
resolution_difference = abs(playlist_resolution - int(resolution))
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,
)
playlists_filtered.sort(key=sort_key)
return playlists_filtered[0]
playlist_results.sort(key=sort_key)
return playlist_results[0][1]
def get_best_stereo_audio_playlist(
self,
@@ -263,16 +266,34 @@ class AppleMusicMusicVideoInterface:
return selected
def get_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
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 == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
),
(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,
@@ -282,14 +303,11 @@ class AppleMusicMusicVideoInterface:
stream_info = StreamInfo()
if MusicVideoCodec.ASK not in codec_priority:
for codec in codec_priority:
playlist = self.get_video_playlist_from_resolution(
playlist_master_m3u8_obj.playlists,
codec,
resolution,
)
if playlist:
break
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
@@ -300,9 +318,14 @@ class AppleMusicMusicVideoInterface:
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_text(stream_info.stream_url))
stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj)
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
@@ -324,8 +347,12 @@ class AppleMusicMusicVideoInterface:
stream_info.stream_url = playlist["uri"]
stream_info.codec = playlist["group_id"]
playlist_m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj)
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
@@ -334,12 +361,14 @@ class AppleMusicMusicVideoInterface:
stream_info: StreamInfoAv,
cdm: Cdm,
) -> DecryptionKeyAv:
decryption_key_video = await self.interface.get_decryption_key(
decryption_key_video = await AppleMusicInterface.get_decryption_key(
self,
stream_info.video_track.widevine_pssh,
stream_info.media_id,
cdm,
)
decryption_key_audio = await self.interface.get_decryption_key(
decryption_key_audio = await AppleMusicInterface.get_decryption_key(
self,
stream_info.audio_track.widevine_pssh,
stream_info.media_id,
cdm,
+28 -25
View File
@@ -1,3 +1,4 @@
import asyncio
import base64
import datetime
import json
@@ -12,7 +13,7 @@ from InquirerPy.base.control import Choice
from pywidevine import PSSH, Cdm
from pywidevine.license_protocol_pb2 import WidevinePsshData
from ..utils import get_response_text
from ..utils import get_response
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
@@ -29,12 +30,9 @@ from .types import (
logger = logging.getLogger(__name__)
class AppleMusicSongInterface:
def __init__(
self,
interface: AppleMusicInterface,
) -> None:
self.interface = interface
class AppleMusicSongInterface(AppleMusicInterface):
def __init__(self, interface: AppleMusicInterface):
self.__dict__.update(interface.__dict__)
async def get_lyrics(
self,
@@ -49,8 +47,8 @@ class AppleMusicSongInterface:
or "lyrics" not in song_metadata["relationships"]
):
song_metadata = (
await self.interface.apple_music_api.get_song(
self.interface.get_media_id_of_library_media(song_metadata)
await self.apple_music_api.get_song(
self.get_media_id_of_library_media(song_metadata)
)
)["data"][0]
@@ -109,9 +107,11 @@ class AppleMusicSongInterface:
index += 1
return Lyrics(
synced="\n".join(synced_lyrics + ["\n"]),
unsynced="\n\n".join(
["\n".join(lyric_group) for lyric_group in unsynced_lyrics]
synced="\n".join(synced_lyrics + ["\n"]) if synced_lyrics else None,
unsynced=(
"\n\n".join(["\n".join(lyric_group) for lyric_group in unsynced_lyrics])
if unsynced_lyrics
else None
),
)
@@ -194,7 +194,7 @@ class AppleMusicSongInterface:
composer_sort=webplayback_metadata.get("sort-composer"),
copyright=webplayback_metadata.get("copyright"),
date=(
self.interface.parse_date(webplayback_metadata["releaseDate"])
self.parse_date(webplayback_metadata["releaseDate"])
if webplayback_metadata.get("releaseDate")
else None
),
@@ -229,7 +229,7 @@ class AppleMusicSongInterface:
if not m3u8_master_url:
return None
m3u8_master_obj = m3u8.loads(await get_response_text(m3u8_master_url))
m3u8_master_obj = m3u8.loads((await get_response(m3u8_master_url)).text)
m3u8_master_data = m3u8_master_obj.data
if codec == SongCodec.ASK:
@@ -273,7 +273,7 @@ class AppleMusicSongInterface:
"com.apple.streamingkeydelivery",
)
else:
m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
stream_info.widevine_pssh = self._get_drm_uri_from_m3u8_keys(
m3u8_obj,
@@ -384,7 +384,7 @@ class AppleMusicSongInterface:
i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor
)["URL"]
m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
stream_info_av = StreamInfoAv(
@@ -414,17 +414,19 @@ class AppleMusicSongInterface:
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
challenge = base64.b64encode(
cdm.get_license_challenge(cdm_session, pssh_obj)
).decode()
license_response = (
await self.interface.apple_music_api.get_license_exchange(
stream_info.media_id,
stream_info.audio_track.widevine_pssh,
challenge,
await asyncio.to_thread(
cdm.get_license_challenge, cdm_session, pssh_obj
)
).decode()
license_response = await self.apple_music_api.get_license_exchange(
stream_info.media_id,
stream_info.audio_track.widevine_pssh,
challenge,
)
cdm.parse_license(cdm_session, license_response["license"])
await asyncio.to_thread(
cdm.parse_license, cdm_session, license_response["license"]
)
decryption_key = next(
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
@@ -448,7 +450,8 @@ class AppleMusicSongInterface:
cdm: Cdm,
) -> DecryptionKeyAv:
return DecryptionKeyAv(
audio_track=await self.interface.get_decryption_key(
audio_track=await AppleMusicInterface.get_decryption_key(
self,
stream_info.audio_track.widevine_pssh,
stream_info.media_id,
cdm,
+5 -5
View File
@@ -7,14 +7,14 @@ from ..interface.enums import UploadedVideoQuality
from ..interface.types import MediaTags
from .constants import UPLOADED_VIDEO_QUALITY_RANK
from .interface import AppleMusicInterface
from .types import StreamInfo, StreamInfoAv, MediaFileFormat
from .types import MediaFileFormat, StreamInfo, StreamInfoAv
logger = logging.getLogger(__name__)
class AppleMusicUploadedVideoInterface:
class AppleMusicUploadedVideoInterface(AppleMusicInterface):
def __init__(self, interface: AppleMusicInterface):
self.interface = interface
self.__dict__.update(interface.__dict__)
def get_stream_url_best(self, metadata: dict) -> str:
best_quality = next(
@@ -76,10 +76,10 @@ class AppleMusicUploadedVideoInterface:
tags = MediaTags(
artist=attributes.get("artistName"),
date=self.interface.parse_date(upload_date) if upload_date else None,
date=self.parse_date(upload_date) if upload_date else None,
title=attributes.get("name"),
title_id=int(metadata["id"]),
storefront=int(self.interface.itunes_api.storefront_id.split("-")[0]),
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
)
logger.debug(f"Tags: {tags}")
+41 -38
View File
@@ -44,22 +44,18 @@ class MediaTags:
def as_mp4_tags(self, date_format: str = None) -> dict:
disc_mp4 = [
[
self.disc if self.disc is not None else 0,
self.disc_total if self.disc_total is not None else 0,
]
self.disc if self.disc is not None else 0,
self.disc_total if self.disc_total is not None else 0,
]
if disc_mp4[0][0] == 0 and disc_mp4[0][1] == 0:
disc_mp4 = [None]
if disc_mp4[0] == 0 and disc_mp4[1] == 0:
disc_mp4 = None
track_mp4 = [
[
self.track if self.track is not None else 0,
self.track_total if self.track_total is not None else 0,
]
self.track if self.track is not None else 0,
self.track_total if self.track_total is not None else 0,
]
if track_mp4[0][0] == 0 and track_mp4[0][1] == 0:
track_mp4 = [None]
if track_mp4[0] == 0 and track_mp4[1] == 0:
track_mp4 = None
if isinstance(self.date, datetime.date):
if date_format is None:
@@ -72,35 +68,40 @@ class MediaTags:
date_mp4 = None
mp4_tags = {
"\xa9alb": [self.album],
"aART": [self.album_artist],
"plID": [self.album_id],
"soal": [self.album_sort],
"\xa9ART": [self.artist],
"atID": [self.artist_id],
"soar": [self.artist_sort],
"\xa9cmt": [self.comment],
"cpil": [bool(self.compilation) if self.compilation is not None else None],
"\xa9wrt": [self.composer],
"cmID": [self.composer_id],
"soco": [self.composer_sort],
"cprt": [self.copyright],
"\xa9day": [date_mp4],
"\xa9alb": self.album,
"aART": self.album_artist,
"plID": self.album_id,
"soal": self.album_sort,
"\xa9ART": self.artist,
"atID": self.artist_id,
"soar": self.artist_sort,
"\xa9cmt": self.comment,
"cpil": bool(self.compilation) if self.compilation is not None else None,
"\xa9wrt": self.composer,
"cmID": self.composer_id,
"soco": self.composer_sort,
"cprt": self.copyright,
"\xa9day": date_mp4,
"disk": disc_mp4,
"pgap": [bool(self.gapless) if self.gapless is not None else None],
"\xa9gen": [self.genre],
"\xa9lyr": [self.lyrics],
"geID": [self.genre_id],
"stik": [int(self.media_type) if self.media_type is not None else None],
"rtng": [int(self.rating) if self.rating is not None else None],
"sfID": [self.storefront],
"\xa9nam": [self.title],
"cnID": [self.title_id],
"sonm": [self.title_sort],
"pgap": bool(self.gapless) if self.gapless is not None else None,
"\xa9gen": self.genre,
"\xa9lyr": self.lyrics,
"geID": self.genre_id,
"stik": int(self.media_type) if self.media_type is not None else None,
"rtng": int(self.rating) if self.rating is not None else None,
"sfID": self.storefront,
"\xa9nam": self.title,
"cnID": self.title_id,
"sonm": self.title_sort,
"trkn": track_mp4,
"xid ": [self.xid],
"xid ": self.xid,
}
return {
k: ([v] if not isinstance(v, bool) else v)
for k, v in mp4_tags.items()
if v is not None
}
return {k: v for k, v in mp4_tags.items() if v[0] is not None}
@dataclass
@@ -118,6 +119,8 @@ class StreamInfo:
playready_pssh: str = None
fairplay_key: str = None
codec: str = None
width: int = None
height: int = None
@dataclass
+25 -15
View File
@@ -20,11 +20,14 @@ def safe_json(httpx_response: httpx.Response) -> dict:
return {}
async def get_response_text(url: str) -> str:
async with httpx.AsyncClient() as client:
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)
return response.text
raise_for_status(response, valid_responses)
return response
async def async_subprocess(*args: str, silent: bool = False) -> None:
@@ -48,24 +51,31 @@ async def async_subprocess(*args: str, silent: bool = False) -> None:
async def safe_gather(
*tasks: typing.Awaitable[typing.Any],
limit: int = 5,
retries: int = 3,
limit: int = 10,
) -> list[typing.Any]:
semaphore = asyncio.Semaphore(limit)
async def bounded_task(task: typing.Awaitable[typing.Any]) -> typing.Any:
async with semaphore:
last_exception = None
for attempt in range(retries + 1):
try:
return await task
except Exception as e:
last_exception = e
if attempt < retries:
await asyncio.sleep(2**attempt)
return last_exception
return await task
return await asyncio.gather(
*(bounded_task(task) for task in tasks),
return_exceptions=True,
)
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
+5 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gamdl"
version = "2.7"
version = "2.8.2"
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
readme = "README.md"
license = { text = "MIT" }
@@ -8,6 +8,7 @@ requires-python = ">=3.10"
dependencies = [
"async-lru>=2.0.5",
"click>=8.3.0",
"colorama>=0.4.6",
"httpx>=0.28.1",
"inquirerpy>=0.3.4",
"m3u8>=6.0.0",
@@ -17,5 +18,8 @@ dependencies = [
"yt-dlp>=2025.10.22",
]
[project.urls]
Repository = "https://github.com/glomatico/gamdl"
[project.scripts]
gamdl = "gamdl.cli.cli:main"
Generated
+3 -1
View File
@@ -202,11 +202,12 @@ wheels = [
[[package]]
name = "gamdl"
version = "2.7"
version = "2.8.2"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },
{ name = "click" },
{ name = "colorama" },
{ name = "httpx" },
{ name = "inquirerpy" },
{ name = "m3u8" },
@@ -220,6 +221,7 @@ dependencies = [
requires-dist = [
{ name = "async-lru", specifier = ">=2.0.5" },
{ name = "click", specifier = ">=8.3.0" },
{ name = "colorama", specifier = ">=0.4.6" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "inquirerpy", specifier = ">=0.3.4" },
{ name = "m3u8", specifier = ">=6.0.0" },