Compare commits

...

138 Commits

Author SHA1 Message Date
Rafael Moraes 9b701e8ee8 Update license format and add setuptools packages 2026-01-15 22:58:33 -03:00
Rafael Moraes f4e6069e69 Bump version to 2.8.3 2026-01-15 22:50:41 -03:00
Rafael Moraes 841b1edb64 Fix import location for CliConfig in config_file.py 2026-01-15 22:48:58 -03:00
Rafael Moraes ef4b34f3d2 Add pathlib and Csv import to cli_config.py 2026-01-15 22:48:53 -03:00
Rafael Moraes 98980fc130 Refactor CliConfig to separate module 2026-01-15 22:47:51 -03:00
Rafael Moraes 6c84651770 Fix config value check to distinguish None from falsy values 2026-01-15 22:44:08 -03:00
Rafael Moraes f9d3d0a97e Refactor playlist file path formatting logic 2026-01-15 22:41:33 -03:00
Rafael Moraes 9a879c0857 Refactor template variable names for clarity 2026-01-15 22:30:09 -03:00
Rafael Moraes d0ab35383b Remove unnecessary strip() after regex substitution 2026-01-15 22:28:54 -03:00
Rafael Moraes b14004f3e3 Update installation instructions to use pip 2026-01-15 22:26:46 -03:00
Rafael Moraes a6e409d98d Update template variable documentation in README 2026-01-15 22:25:06 -03:00
Rafael Moraes d1c9aea874 Skip config updates for command-line parameters 2026-01-15 22:15:55 -03:00
Rafael Moraes 8c110b4fb9 Refactor file template selection logic 2026-01-15 22:12:03 -03:00
Rafael Moraes e1c8cb51ad Refactor path sanitization and formatting logic 2026-01-15 22:11:14 -03:00
Rafael Moraes 52324d519c Refactor CLI to use dataclass-based config and options 2026-01-15 03:12:39 -03:00
Rafael Moraes 057315524f Add dataclass-click to project dependencies 2026-01-15 03:12:18 -03:00
Rafael Moraes 446636166e Update README with new CLI options 2026-01-04 15:11:09 -03:00
Rafael Moraes 7199cac179 Add support for fetching and applying extra tags 2026-01-04 15:11:02 -03:00
Rafael Moraes be4f30cb54 Add method to extract extra tags from Apple Music previews 2026-01-04 15:10:54 -03:00
Rafael Moraes 83ca91e91c Refactor cover image handling to interface layer 2026-01-03 15:08:35 -03:00
Rafael Moraes 6ed596ca42 Add option to use album release date for songs 2026-01-03 14:43:55 -03:00
Rafael Moraes 414ce749d6 Remove unused httpx import from downloader_base.py 2026-01-03 14:24:06 -03:00
Rafael Moraes 17863b500a Add UnsupportedMediaType exception and checks for downloaders 2026-01-02 13:31:24 -03:00
Rafael Moraes 5e48032f34 Remove redundant error handling in downloaders 2026-01-02 13:23:56 -03:00
Rafael Moraes e2ed443253 Add unified error handling to get_download_item 2026-01-02 13:19:13 -03:00
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
28 changed files with 1915 additions and 1195 deletions
+138 -96
View File
@@ -37,15 +37,14 @@ 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
**Install Gamdl via pipx:**
[pipx](https://pipx.pypa.io/stable/installation/) is recommended for installing Gamdl to avoid dependency conflicts, but you can also use pip.
**Install Gamdl via pip:**
```bash
pipx install gamdl
pip install gamdl
```
**Setup cookies:**
@@ -110,76 +109,82 @@ 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` |
| `--use-album-date` | Use album release date for songs | `false` |
| `--fetch-extra-tags` | Fetch extra tags from preview (normalization and smooth playback) | `false` |
| **Music Video Options** | | |
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
| `--music-video-remux-format` | Music video remux format | `m4v` |
| `--music-video-resolution` | Max music video resolution | `1080p` |
| **Post Video Options** | | |
| `--uploaded-video-quality` | Post video quality | `best` |
### Template Variables
Use these variables in folder/file templates or `--exclude-tags`:
**Tags for templates and exclude-tags:**
| Variable | Description |
| ---------------------------------------------------------------------------- | --------------------------------------------- |
| `{album}`, `{album_artist}`, `{album_id}`, `{album_sort}` | Album info |
| `{artist}`, `{artist_id}`, `{artist_sort}` | Artist info |
| `{title}`, `{title_id}`, `{title_sort}` | Title info |
| `{composer}`, `{composer_id}`, `{composer_sort}` | Composer info |
| `{track}`, `{track_total}`, `{disc}`, `{disc_total}` | Track numbers |
| `{genre}`, `{genre_id}` | Genre info |
| `{date}` | Release date (supports strftime: `{date:%Y}`) |
| `{playlist_artist}`, `{playlist_id}`, `{playlist_title}`, `{playlist_track}` | Playlist info |
| `{compilation}`, `{gapless}`, `{rating}` | Media properties |
| `{comment}`, `{copyright}`, `{lyrics}`, `{cover}` | Additional metadata |
| `{media_type}`, `{storefront}`, `{xid}` | Technical info |
| `all` | Special: Skip all tagging |
- `album`, `album_artist`, `album_id`
- `artist`, `artist_id`
- `composer`, `composer_id`
- `date` (supports strftime format: `{date:%Y}`)
- `disc`, `disc_total`
- `media_type`
- `playlist_artist`, `playlist_id`, `playlist_title`, `playlist_track`
- `title`, `title_id`
- `track`, `track_total`
**Tags for exclude-tags only:**
- `album_sort`, `artist_sort`, `composer_sort`, `title_sort`
- `comment`, `compilation`, `copyright`, `cover`, `gapless`, `genre`, `genre_id`, `lyrics`, `rating`, `storefront`, `xid`
- `all` (special: skip all tagging)
### Logging Level
@@ -221,7 +226,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 +255,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 +286,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 +356,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.3"
+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(
+136 -416
View File
@@ -1,493 +1,216 @@
import inspect
import asyncio
import logging
from functools import wraps
from pathlib import Path
import click
import colorama
from dataclass_click import dataclass_click
from .. import __version__
from ..api import AppleMusicApi
from ..api import AppleMusicApi, ItunesApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicDownloader,
AppleMusicMusicVideoDownloader,
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
CoverFormat,
DownloadItem,
DownloadMode,
MediaDownloadConfigurationError,
MediaFormatNotAvailableError,
MediaNotStreamableError,
RemuxFormatMusicVideo,
GamdlError,
RemuxMode,
)
from ..interface import (
MusicVideoCodec,
MusicVideoResolution,
AppleMusicInterface,
AppleMusicMusicVideoInterface,
AppleMusicSongInterface,
AppleMusicUploadedVideoInterface,
SongCodec,
SyncedLyricsFormat,
UploadedVideoQuality,
)
from .cli_config import CliConfig
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 CustomLoggerFormatter, prompt_path
logger = logging.getLogger(__name__)
api_sig = inspect.signature(AppleMusicApi.from_netscape_cookies)
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
song_downloader_sig = inspect.signature(AppleMusicSongDownloader.__init__)
uploaded_video_downloader_sig = inspect.signature(
AppleMusicUploadedVideoDownloader.__init__
)
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")
# CLI specific options
@click.argument(
"urls",
nargs=-1,
type=str,
required=True,
)
@click.option(
"--read-urls-as-txt",
"-r",
is_flag=True,
help="Read URLs from text files",
)
@click.option(
"--config-path",
type=click.Path(file_okay=True, dir_okay=False, writable=True, resolve_path=True),
default=str(Path.home() / ".gamdl" / "config.ini"),
help="Config file path",
)
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
default="INFO",
help="Logging level",
)
@click.option(
"--log-file",
type=click.Path(file_okay=True, dir_okay=False, writable=True, resolve_path=True),
default=None,
help="Log file path",
)
@click.option(
"--no-exceptions",
is_flag=True,
help="Don't print exceptions",
)
# API specific options
@click.option(
"--cookies-path",
"-c",
type=PathPrompt(is_file=True),
default=api_sig.parameters["cookies_path"].default,
help="Cookies file path",
)
@click.option(
"--language",
"-l",
type=str,
default=api_sig.parameters["language"].default,
help="Metadata language",
)
# Base Downloader specific options
@click.option(
"--output-path",
"-o",
type=click.Path(file_okay=False, dir_okay=True, writable=True, resolve_path=True),
default=base_downloader_sig.parameters["output_path"].default,
help="Output directory path",
)
@click.option(
"--temp-path",
type=click.Path(file_okay=False, dir_okay=True, writable=True, resolve_path=True),
default=base_downloader_sig.parameters["temp_path"].default,
help="Temporary directory path",
)
@click.option(
"--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",
)
@click.option(
"--overwrite",
is_flag=True,
help="Overwrite existing files",
default=base_downloader_sig.parameters["overwrite"].default,
)
@click.option(
"--save-cover",
"-s",
is_flag=True,
help="Save cover as separate file",
default=base_downloader_sig.parameters["save_cover"].default,
)
@click.option(
"--save-playlist",
is_flag=True,
help="Save M3U8 playlist file",
default=base_downloader_sig.parameters["save_playlist"].default,
)
@click.option(
"--nm3u8dlre-path",
type=str,
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
help="N_m3u8DL-RE executable path",
)
@click.option(
"--mp4decrypt-path",
type=str,
default=base_downloader_sig.parameters["mp4decrypt_path"].default,
help="mp4decrypt executable path",
)
@click.option(
"--ffmpeg-path",
type=str,
default=base_downloader_sig.parameters["ffmpeg_path"].default,
help="FFmpeg executable path",
)
@click.option(
"--mp4box-path",
type=str,
default=base_downloader_sig.parameters["mp4box_path"].default,
help="MP4Box executable path",
)
@click.option(
"--download-mode",
type=DownloadMode,
default=base_downloader_sig.parameters["download_mode"].default,
help="Download mode",
)
@click.option(
"--remux-mode",
type=RemuxMode,
default=base_downloader_sig.parameters["remux_mode"].default,
help="Remux mode",
)
@click.option(
"--cover-format",
type=CoverFormat,
default=base_downloader_sig.parameters["cover_format"].default,
help="Cover format",
)
@click.option(
"--album-folder-template",
type=str,
default=base_downloader_sig.parameters["album_folder_template"].default,
help="Album folder template",
)
@click.option(
"--compilation-folder-template",
type=str,
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(
"--no-album-file-template",
type=str,
default=base_downloader_sig.parameters["no_album_file_template"].default,
help="No album file template",
)
@click.option(
"--playlist-file-template",
type=str,
default=base_downloader_sig.parameters["playlist_file_template"].default,
help="Playlist template",
)
@click.option(
"--date-tag-template",
type=str,
default=base_downloader_sig.parameters["date_tag_template"].default,
help="Date tag template",
)
@click.option(
"--exclude-tags",
type=Csv(str),
default=base_downloader_sig.parameters["exclude_tags"].default,
help="Comma-separated tags to exclude",
)
@click.option(
"--cover-size",
type=int,
default=base_downloader_sig.parameters["cover_size"].default,
help="Cover size in pixels",
)
@click.option(
"--truncate",
type=int,
default=base_downloader_sig.parameters["truncate"].default,
help="Max filename length",
)
# DownloaderSong specific options
@click.option(
"--codec-song",
type=SongCodec,
default=song_downloader_sig.parameters["codec"].default,
help="Song codec",
)
@click.option(
"--synced-lyrics-format",
type=SyncedLyricsFormat,
default=song_downloader_sig.parameters["synced_lyrics_format"].default,
help="Synced lyrics format",
)
@click.option(
"--no-synced-lyrics",
is_flag=True,
help="Don't download synced lyrics",
default=song_downloader_sig.parameters["no_synced_lyrics"].default,
)
@click.option(
"--synced-lyrics-only",
is_flag=True,
help="Download only synced lyrics",
default=song_downloader_sig.parameters["synced_lyrics_only"].default,
)
# DownloaderMusicVideo specific options
@click.option(
"--music-video-codec-priority",
type=Csv(MusicVideoCodec),
default=music_video_downloader_sig.parameters["codec_priority"].default,
help="Comma-separated codec priority",
)
@click.option(
"--music-video-remux-format",
type=RemuxFormatMusicVideo,
default=music_video_downloader_sig.parameters["remux_format"].default,
help="Music video remux format",
)
@click.option(
"--music-video-resolution",
type=MusicVideoResolution,
default=music_video_downloader_sig.parameters["resolution"].default,
help="Max music video resolution",
)
# DownloaderUploadedVideo specific options
@click.option(
"--uploaded-video-quality",
type=UploadedVideoQuality,
default=uploaded_video_downloader_sig.parameters["quality"].default,
help="Post video quality",
)
# This option should always be last
@click.option(
"--no-config-file",
"-n",
is_flag=True,
callback=load_config_file,
help="Don't use a config file",
)
@dataclass_click(CliConfig)
@make_sync
async def main(
urls: list[str],
read_urls_as_txt: bool,
config_path: str,
log_level: str,
log_file: str,
no_exceptions: bool,
cookies_path: str,
language: str,
output_path: str,
temp_path: str,
wvd_path: str,
overwrite: bool,
save_cover: bool,
save_playlist: bool,
nm3u8dlre_path: str,
mp4decrypt_path: str,
ffmpeg_path: str,
mp4box_path: 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,
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,
synced_lyrics_format: SyncedLyricsFormat,
no_synced_lyrics: bool,
synced_lyrics_only: bool,
music_video_codec_priority: list[MusicVideoCodec],
music_video_remux_format: RemuxFormatMusicVideo,
music_video_resolution: MusicVideoResolution,
uploaded_video_quality: UploadedVideoQuality,
*args,
**kwargs,
):
async def main(config: CliConfig):
colorama.just_fix_windows_console()
root_logger = logging.getLogger(__name__.split(".")[0])
root_logger.setLevel(log_level)
root_logger.setLevel(config.log_level)
root_logger.propagate = False
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(CustomLoggerFormatter())
root_logger.addHandler(stream_handler)
if log_file:
file_handler = logging.FileHandler(log_file, encoding="utf-8")
if config.log_file:
file_handler = logging.FileHandler(config.log_file, encoding="utf-8")
file_handler.setFormatter(CustomLoggerFormatter(use_colors=False))
root_logger.addHandler(file_handler)
logger.info(f"Starting Gamdl {__version__}")
api = AppleMusicApi.from_netscape_cookies(
cookies_path=cookies_path,
language=language,
)
await api.setup()
if not config.no_config_file:
config_file = ConfigFile(config.config_path)
config_file.cleanup_unknown_params()
config_file.add_params_default_to_config()
config = config_file.update_params_from_config(config)
if not api.account_info["meta"]["subscription"]["active"]:
if config.use_wrapper:
apple_music_api = await AppleMusicApi.create_from_wrapper(
wrapper_account_url=config.wrapper_account_url,
language=config.language,
)
else:
cookies_path = prompt_path(config.cookies_path)
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
cookies_path=cookies_path,
language=config.language,
)
itunes_api = ItunesApi(
apple_music_api.storefront,
apple_music_api.language,
)
if not apple_music_api.active_subscription:
logger.critical(
"No active Apple Music subscription found, you won't be able to download"
" anything"
)
return
if 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,
overwrite=overwrite,
save_cover=save_cover,
save_playlist=save_playlist,
nm3u8dlre_path=nm3u8dlre_path,
mp4decrypt_path=mp4decrypt_path,
ffmpeg_path=ffmpeg_path,
mp4box_path=mp4box_path,
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,
no_album_file_template=no_album_file_template,
playlist_file_template=playlist_file_template,
date_tag_template=date_tag_template,
exclude_tags=exclude_tags,
cover_size=cover_size,
truncate=truncate,
output_path=config.output_path,
temp_path=config.temp_path,
wvd_path=config.wvd_path,
overwrite=config.overwrite,
save_cover=config.save_cover,
save_playlist=config.save_playlist,
nm3u8dlre_path=config.nm3u8dlre_path,
mp4decrypt_path=config.mp4decrypt_path,
ffmpeg_path=config.ffmpeg_path,
mp4box_path=config.mp4box_path,
amdecrypt_path=config.amdecrypt_path,
use_wrapper=config.use_wrapper,
wrapper_decrypt_ip=config.wrapper_decrypt_ip,
download_mode=config.download_mode,
remux_mode=config.remux_mode,
cover_format=config.cover_format,
album_folder_template=config.album_folder_template,
compilation_folder_template=config.compilation_folder_template,
no_album_folder_template=config.no_album_folder_template,
single_disc_file_template=config.single_disc_file_template,
multi_disc_file_template=config.multi_disc_file_template,
no_album_file_template=config.no_album_file_template,
playlist_file_template=config.playlist_file_template,
date_tag_template=config.date_tag_template,
exclude_tags=config.exclude_tags,
cover_size=config.cover_size,
truncate=config.truncate,
)
base_downloader.setup()
song_downloader = AppleMusicSongDownloader(
base_downloader,
codec=codec_song,
synced_lyrics_format=synced_lyrics_format,
no_synced_lyrics=no_synced_lyrics,
synced_lyrics_only=synced_lyrics_only,
base_downloader=base_downloader,
interface=song_interface,
codec=config.song_codec,
synced_lyrics_format=config.synced_lyrics_format,
no_synced_lyrics=config.no_synced_lyrics,
synced_lyrics_only=config.synced_lyrics_only,
use_album_date=config.use_album_date,
fetch_extra_tags=config.fetch_extra_tags,
)
song_downloader.setup()
music_video_downloader = AppleMusicMusicVideoDownloader(
base_downloader,
codec_priority=music_video_codec_priority,
remux_format=music_video_remux_format,
resolution=music_video_resolution,
base_downloader=base_downloader,
interface=music_video_interface,
codec_priority=config.music_video_codec_priority,
remux_format=config.music_video_remux_format,
resolution=config.music_video_resolution,
)
music_video_downloader.setup()
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
base_downloader,
quality=uploaded_video_quality,
base_downloader=base_downloader,
interface=uploaded_video_interface,
quality=config.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:
if not config.synced_lyrics_only:
if not base_downloader.full_ffmpeg_path and (
remux_mode == RemuxMode.FFMPEG or download_mode == DownloadMode.NM3U8DLRE
config.remux_mode == RemuxMode.FFMPEG
or config.download_mode == DownloadMode.NM3U8DLRE
):
logger.critical(X_NOT_IN_PATH.format("ffmpeg", ffmpeg_path))
return
if not base_downloader.full_mp4box_path and remux_mode == RemuxMode.MP4BOX:
logger.critical(X_NOT_IN_PATH.format("MP4Box", mp4box_path))
logger.critical(X_NOT_IN_PATH.format("ffmpeg", config.ffmpeg_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
)
not base_downloader.full_mp4box_path
and config.remux_mode == RemuxMode.MP4BOX
):
logger.critical(X_NOT_IN_PATH.format("mp4decrypt", mp4decrypt_path))
logger.critical(X_NOT_IN_PATH.format("MP4Box", config.mp4box_path))
return
if not base_downloader.full_mp4decrypt_path and (
config.song_codec not in (SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY)
or config.remux_mode == RemuxMode.MP4BOX
):
logger.critical(X_NOT_IN_PATH.format("mp4decrypt", config.mp4decrypt_path))
return
if (
download_mode == DownloadMode.NM3U8DLRE
config.download_mode == DownloadMode.NM3U8DLRE
and not base_downloader.full_nm3u8dlre_path
):
logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", nm3u8dlre_path))
logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", config.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 config.use_wrapper and not base_downloader.full_amdecrypt_path:
logger.critical(X_NOT_IN_PATH.format("amdecrypt", config.amdecrypt_path))
return
if not codec_song.is_legacy():
if not config.song_codec.is_legacy() and not config.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."
)
if read_urls_as_txt:
if config.read_urls_as_txt:
urls_from_file = []
for url in urls:
for url in config.urls:
if Path(url).is_file() and Path(url).exists():
urls_from_file.extend(
[
@@ -497,6 +220,8 @@ async def main(
]
)
urls = urls_from_file
else:
urls = config.urls
error_count = 0
for url_index, url in enumerate(urls, 1):
@@ -524,7 +249,7 @@ async def main(
error_count += 1
logger.error(
url_progress + f' Error processing "{url}"',
exc_info=not no_exceptions,
exc_info=not config.no_exceptions,
)
if not download_queue:
@@ -550,12 +275,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}'
)
@@ -566,7 +286,7 @@ async def main(
error_count += 1
logger.error(
download_queue_progress + f' Error downloading "{media_title}"',
exc_info=not no_exceptions,
exc_info=not config.no_exceptions,
)
logger.info(f"Finished with {error_count} error(s)")
+519
View File
@@ -0,0 +1,519 @@
import inspect
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated
import click
from click.types import BoolParamType, FuncParamType, IntParamType, StringParamType
from dataclass_click import argument, option
from ..api import AppleMusicApi
from ..downloader import (
AppleMusicBaseDownloader,
AppleMusicMusicVideoDownloader,
AppleMusicSongDownloader,
AppleMusicUploadedVideoDownloader,
DownloadMode,
RemuxFormatMusicVideo,
RemuxMode,
)
from ..interface import (
CoverFormat,
MusicVideoCodec,
MusicVideoResolution,
SongCodec,
SyncedLyricsFormat,
UploadedVideoQuality,
)
from .utils import Csv
api_from_cookies_sig = inspect.signature(AppleMusicApi.create_from_netscape_cookies)
api_from_wrapper_sig = inspect.signature(AppleMusicApi.create_from_wrapper)
api_sig = inspect.signature(AppleMusicApi.__init__)
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
song_downloader_sig = inspect.signature(AppleMusicSongDownloader.__init__)
uploaded_video_downloader_sig = inspect.signature(
AppleMusicUploadedVideoDownloader.__init__
)
@dataclass
class CliConfig:
# CLI specific options
urls: Annotated[
list[str],
argument(
nargs=-1,
type=str,
required=True,
),
]
read_urls_as_txt: Annotated[
bool,
option(
"--read-urls-as-txt",
"-r",
help="Read URLs from text files",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
config_path: Annotated[
str,
option(
"--config-path",
help="Config file path",
default=str(Path.home() / ".gamdl" / "config.ini"),
type=click.Path(
file_okay=True,
dir_okay=False,
writable=True,
resolve_path=True,
),
),
]
log_level: Annotated[
str,
option(
"--log-level",
help="Logging level",
default="INFO",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
),
]
log_file: Annotated[
str,
option(
"--log-file",
help="Log file path",
default=None,
type=click.Path(
file_okay=True,
dir_okay=False,
writable=True,
resolve_path=True,
),
),
]
no_exceptions: Annotated[
bool,
option(
"--no-exceptions",
help="Don't print exceptions",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
# API specific options
cookies_path: Annotated[
str,
option(
"--cookies-path",
"-c",
help="Cookies file path",
default=api_from_cookies_sig.parameters["cookies_path"].default,
type=click.Path(
file_okay=True,
dir_okay=False,
readable=True,
resolve_path=True,
),
),
]
wrapper_account_url: Annotated[
str,
option(
"--wrapper-account-url",
help="Wrapper account URL",
default=api_from_wrapper_sig.parameters["wrapper_account_url"].default,
type=StringParamType(),
),
]
language: Annotated[
str,
option(
"--language",
"-l",
help="Metadata language",
default=api_sig.parameters["language"].default,
type=StringParamType(),
),
]
# Base Downloader specific options
output_path: Annotated[
str,
option(
"--output-path",
"-o",
help="Output directory path",
default=base_downloader_sig.parameters["output_path"].default,
type=click.Path(
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
),
),
]
temp_path: Annotated[
str,
option(
"--temp-path",
help="Temporary directory path",
default=base_downloader_sig.parameters["temp_path"].default,
type=click.Path(
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
),
),
]
wvd_path: Annotated[
str,
option(
"--wvd-path",
help=".wvd file path",
default=base_downloader_sig.parameters["wvd_path"].default,
type=click.Path(
file_okay=False,
dir_okay=True,
writable=True,
resolve_path=True,
),
),
]
overwrite: Annotated[
bool,
option(
"--overwrite",
help="Overwrite existing files",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
save_cover: Annotated[
bool,
option(
"--save-cover",
"-s",
help="Save cover as separate file",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
save_playlist: Annotated[
bool,
option(
"--save-playlist",
help="Save M3U8 playlist file",
type=BoolParamType(),
default=False,
is_flag=True,
),
]
nm3u8dlre_path: Annotated[
str,
option(
"--nm3u8dlre-path",
help="N_m3u8DL-RE executable path",
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
type=StringParamType(),
),
]
mp4decrypt_path: Annotated[
str,
option(
"--mp4decrypt-path",
help="mp4decrypt executable path",
default=base_downloader_sig.parameters["mp4decrypt_path"].default,
type=StringParamType(),
),
]
ffmpeg_path: Annotated[
str,
option(
"--ffmpeg-path",
help="FFmpeg executable path",
default=base_downloader_sig.parameters["ffmpeg_path"].default,
type=StringParamType(),
),
]
mp4box_path: Annotated[
str,
option(
"--mp4box-path",
help="MP4Box executable path",
default=base_downloader_sig.parameters["mp4box_path"].default,
type=StringParamType(),
),
]
amdecrypt_path: Annotated[
str,
option(
"--amdecrypt-path",
help="amdecrypt executable path",
default=base_downloader_sig.parameters["amdecrypt_path"].default,
type=StringParamType(),
),
]
use_wrapper: Annotated[
bool,
option(
"--use-wrapper",
help="Use wrapper and amdecrypt for decrypting songs",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
wrapper_decrypt_ip: Annotated[
str,
option(
"--wrapper-decrypt-ip",
help="IP address and port for wrapper decryption",
default=base_downloader_sig.parameters["wrapper_decrypt_ip"].default,
type=StringParamType(),
),
]
download_mode: Annotated[
DownloadMode,
option(
"--download-mode",
help="Download mode",
default=base_downloader_sig.parameters["download_mode"].default,
type=FuncParamType(DownloadMode),
),
]
remux_mode: Annotated[
RemuxMode,
option(
"--remux-mode",
help="Remux mode",
default=base_downloader_sig.parameters["remux_mode"].default,
type=FuncParamType(RemuxMode),
),
]
cover_format: Annotated[
CoverFormat,
option(
"--cover-format",
help="Cover format",
default=base_downloader_sig.parameters["cover_format"].default,
type=FuncParamType(CoverFormat),
),
]
album_folder_template: Annotated[
str,
option(
"--album-folder-template",
help="Album folder template",
default=base_downloader_sig.parameters["album_folder_template"].default,
type=StringParamType(),
),
]
compilation_folder_template: Annotated[
str,
option(
"--compilation-folder-template",
help="Compilation folder template",
default=base_downloader_sig.parameters[
"compilation_folder_template"
].default,
type=StringParamType(),
),
]
no_album_folder_template: Annotated[
str,
option(
"--no-album-folder-template",
help="No album folder template",
default=base_downloader_sig.parameters["no_album_folder_template"].default,
type=StringParamType(),
),
]
single_disc_file_template: Annotated[
str,
option(
"--single-disc-file-template",
help="Single disc file template",
default=base_downloader_sig.parameters["single_disc_file_template"].default,
type=StringParamType(),
),
]
multi_disc_file_template: Annotated[
str,
option(
"--multi-disc-file-template",
help="Multi disc file template",
default=base_downloader_sig.parameters["multi_disc_file_template"].default,
type=StringParamType(),
),
]
no_album_file_template: Annotated[
str,
option(
"--no-album-file-template",
help="No album file template",
default=base_downloader_sig.parameters["no_album_file_template"].default,
type=StringParamType(),
),
]
playlist_file_template: Annotated[
str,
option(
"--playlist-file-template",
help="Playlist file template",
default=base_downloader_sig.parameters["playlist_file_template"].default,
type=StringParamType(),
),
]
date_tag_template: Annotated[
str,
option(
"--date-tag-template",
help="Date tag template",
default=base_downloader_sig.parameters["date_tag_template"].default,
type=StringParamType(),
),
]
exclude_tags: Annotated[
list[str],
option(
"--exclude-tags",
help="Comma-separated tags to exclude",
default=base_downloader_sig.parameters["exclude_tags"].default,
type=Csv(str),
),
]
cover_size: Annotated[
int,
option(
"--cover-size",
help="Cover size in pixels",
default=base_downloader_sig.parameters["cover_size"].default,
type=IntParamType(),
),
]
truncate: Annotated[
int,
option(
"--truncate",
help="Max filename length",
default=base_downloader_sig.parameters["truncate"].default,
type=IntParamType(),
),
]
# DownloaderSong specific options
song_codec: Annotated[
SongCodec,
option(
"--song-codec",
help="Song codec",
default=song_downloader_sig.parameters["codec"].default,
type=FuncParamType(SongCodec),
),
]
synced_lyrics_format: Annotated[
SyncedLyricsFormat,
option(
"--synced-lyrics-format",
help="Synced lyrics format",
default=song_downloader_sig.parameters["synced_lyrics_format"].default,
type=FuncParamType(SyncedLyricsFormat),
),
]
no_synced_lyrics: Annotated[
bool,
option(
"--no-synced-lyrics",
help="Don't download synced lyrics",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
synced_lyrics_only: Annotated[
bool,
option(
"--synced-lyrics-only",
help="Download only synced lyrics",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
use_album_date: Annotated[
bool,
option(
"--use-album-date",
help="Use album release date for songs",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
fetch_extra_tags: Annotated[
bool,
option(
"--fetch-extra-tags",
help="Fetch extra tags from preview (normalization and smooth playback)",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
# DownloaderMusicVideo specific options
music_video_codec_priority: Annotated[
list[MusicVideoCodec],
option(
"--music-video-codec-priority",
help="Comma-separated codec priority",
default=music_video_downloader_sig.parameters["codec_priority"].default,
type=Csv(MusicVideoCodec),
),
]
music_video_remux_format: Annotated[
RemuxFormatMusicVideo,
option(
"--music-video-remux-format",
help="Music video remux format",
default=music_video_downloader_sig.parameters["remux_format"].default,
type=FuncParamType(RemuxFormatMusicVideo),
),
]
music_video_resolution: Annotated[
MusicVideoResolution,
option(
"--music-video-resolution",
help="Max music video resolution",
default=music_video_downloader_sig.parameters["resolution"].default,
type=FuncParamType(MusicVideoResolution),
),
]
# DownloaderUploadedVideo specific options
uploaded_video_quality: Annotated[
UploadedVideoQuality,
option(
"--uploaded-video-quality",
help="Post video quality",
default=uploaded_video_downloader_sig.parameters["quality"].default,
type=FuncParamType(UploadedVideoQuality),
),
]
no_config_file: Annotated[
bool,
option(
"--no-config-file",
"-n",
help="Don't use a config file",
default=False,
type=BoolParamType(),
is_flag=True,
),
]
+110 -40
View File
@@ -1,11 +1,23 @@
import configparser
import typing
from enum import Enum
from dataclasses import dataclass
from pathlib import Path
from typing import get_type_hints
import click
import click.types as click_types
from dataclass_click.dataclass_click import _DelayedCall
from .cli_config import CliConfig
from .constants import EXCLUDED_CONFIG_FILE_PARAMS
from .utils import Csv
@dataclass
class ParameterInfo:
name: str
default: typing.Any
type: typing.Any
class ConfigFile:
@@ -16,9 +28,34 @@ class ConfigFile:
) -> None:
self.config_path = config_path
self.section_name = section_name
self.parameters = self._extract_parameters_from_cli_config()
self._read_config_file()
def _extract_parameters_from_cli_config(self) -> dict[str, ParameterInfo]:
parameters = {}
hints = get_type_hints(CliConfig, include_extras=True)
for field_name, hint in hints.items():
if hasattr(hint, "__metadata__"):
for metadata in hint.__metadata__:
if isinstance(metadata, _DelayedCall):
param_type = metadata.kwargs.get("type")
if param_type is None:
raise ValueError(
f"Parameter type for field '{field_name}' "
"could not be determined."
)
parameters[field_name] = ParameterInfo(
name=field_name,
default=metadata.kwargs.get("default"),
type=param_type,
)
break
return parameters
def _read_config_file(self) -> None:
self.config = configparser.ConfigParser(interpolation=None)
@@ -34,71 +71,104 @@ class ConfigFile:
with open(self.config_path, "w", encoding="utf-8") as config_file:
self.config.write(config_file)
def _serialize_param_default(self, param: 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:
def _serialize_param_default(self, param_info: ParameterInfo) -> str:
if param_info.default is None:
return "null"
return ",".join(str(item) for item in param_default)
if isinstance(param_info.type, Csv):
return ",".join(
item.value if hasattr(item, "value") else str(item)
for item in param_info.default
)
if isinstance(param_info.type, click_types.FuncParamType):
return param_info.default.value
if isinstance(param_info.type, click_types.BoolParamType):
return "true" if param_info.default else "false"
if isinstance(
param_info.type,
click_types.Choice
| click_types.Path
| click_types.StringParamType
| click_types.IntParamType,
):
return str(param_info.default)
raise NotImplementedError(
f"Serialization for parameter '{param_info.name}' of type "
f"'{type(param_info.type)}' is not implemented."
)
def _add_param_default_to_config(
self,
param: click.Parameter,
param_info: ParameterInfo,
) -> bool:
if self.config[self.section_name].get(param.name):
if self.config.has_option(self.section_name, param_info.name):
return False
value = self._serialize_param_default(param)
self.config[self.section_name][param.name] = value
value = self._serialize_param_default(param_info)
self.config.set(self.section_name, param_info.name, value)
return True
def _parse_param_from_config(
self,
param: click.Parameter,
param_info: ParameterInfo,
) -> typing.Any:
value = self.config[self.section_name].get(param.name)
value = self.config[self.section_name].get(param_info.name)
if value is None:
return param_info.default
if value == "null":
return None
return param.type_cast_value(None, value)
if not isinstance(param_info.type, click_types.ParamType):
raise NotImplementedError(
f"Parsing for parameter '{param_info.name}' of type "
f"'{type(param_info.type)}' is not implemented."
)
def add_params_default_to_config(
self,
params: list[click.Parameter],
) -> None:
return param_info.type.convert(value, None, None)
def add_params_default_to_config(self) -> None:
has_changes = False
for param in params:
if param.name in EXCLUDED_CONFIG_FILE_PARAMS:
for param_info in self.parameters.values():
if param_info.name in EXCLUDED_CONFIG_FILE_PARAMS:
continue
has_changes = self._add_param_default_to_config(param) or has_changes
has_changes = self._add_param_default_to_config(param_info) or has_changes
if has_changes:
self._write_config_file()
def parse_params_from_config(
self,
params: list[click.Parameter],
) -> dict[str, typing.Any]:
parsed_params = {}
def cleanup_unknown_params(self) -> None:
param_names = {info.name for info in self.parameters.values()}
has_changes = False
for param in params:
parsed_params[param.name] = self._parse_param_from_config(param)
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
return parsed_params
if has_changes:
self._write_config_file()
def update_params_from_config(self, config: CliConfig) -> CliConfig:
updates = {}
click_context = click.get_current_context()
for param_info in self.parameters.values():
if (
click_context.get_parameter_source(param_info.name)
== click.core.ParameterSource.COMMANDLINE
):
continue
if self.config.has_option(self.section_name, param_info.name):
updates[param_info.name] = self._parse_param_from_config(param_info)
config_dict = config.__dict__.copy()
config_dict.update(updates)
return CliConfig(**config_dict)
+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
-4
View File
@@ -1,10 +1,6 @@
import re
DEFAULT_SONG_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
IMAGE_FILE_EXTENSION_MAP = {
"jpeg": ".jpg",
"tiff": ".tif",
}
TEMP_PATH_TEMPLATE = "gamdl_temp_{}"
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
ILLEGAL_CHAR_REPLACEMENT = "_"
+182 -99
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,14 @@ 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,
UnsupportedMediaType,
)
from .types import DownloadItem, UrlInfo
@@ -29,40 +35,87 @@ 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:
download_item = None
if self.flat_filter:
flat_filter_result = self.flat_filter(media_metadata)
if asyncio.iscoroutine(flat_filter_result):
flat_filter_result = await flat_filter_result
if media_metadata["type"] in SONG_MEDIA_TYPE:
download_item = await self.song_downloader.get_download_item(
media_metadata,
playlist_metadata,
)
if flat_filter_result:
return DownloadItem(
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
flat_filter_result=flat_filter_result,
)
if media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
download_item = await self.music_video_downloader.get_download_item(
media_metadata,
playlist_metadata,
)
return await self.get_single_download_item_no_filter(
media_metadata,
playlist_metadata,
)
if media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
download_item = await self.uploaded_video_downloader.get_download_item(
async def get_single_download_item_no_filter(
self,
media_metadata: dict,
playlist_metadata: dict = None,
) -> DownloadItem:
try:
if not self.base_downloader.is_media_streamable(
media_metadata,
):
raise NotStreamable(media_metadata["id"])
if media_metadata["type"] in SONG_MEDIA_TYPE:
if not self.song_downloader:
raise UnsupportedMediaType(media_metadata["type"])
download_item = await self.song_downloader.get_download_item(
media_metadata,
playlist_metadata,
)
if media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
if not self.music_video_downloader:
raise UnsupportedMediaType(media_metadata["type"])
download_item = await self.music_video_downloader.get_download_item(
media_metadata,
playlist_metadata,
)
if media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
if not self.uploaded_video_downloader:
raise UnsupportedMediaType(media_metadata["type"])
download_item = await self.uploaded_video_downloader.get_download_item(
media_metadata,
)
except Exception as e:
download_item = DownloadItem(
media_metadata=media_metadata,
playlist_metadata=playlist_metadata,
error=e,
)
return download_item
@@ -70,28 +123,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 +148,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 +189,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 +203,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 +214,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 +233,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 +248,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 +257,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 +282,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 +294,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 +310,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 +321,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 +337,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 +352,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 +364,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 +375,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 +405,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,11 +482,11 @@ 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,
)
cover_bytes = await self.base_downloader.get_cover_bytes(cover_url)
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
if cover_bytes and (
self.base_downloader.overwrite
or not Path(download_item.cover_path).exists()
@@ -441,6 +521,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,
+143 -159
View File
@@ -2,35 +2,23 @@ import asyncio
import re
import shutil
import uuid
from io import BytesIO
from pathlib import Path
import httpx
from async_lru import alru_cache
from mutagen.mp4 import MP4, MP4Cover
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.enums import CoverFormat
from ..interface.types import MediaTags, PlaylistTags
from ..utils import async_subprocess, raise_for_status
from .constants import (
ILLEGAL_CHAR_REPLACEMENT,
ILLEGAL_CHARS_RE,
IMAGE_FILE_EXTENSION_MAP,
TEMP_PATH_TEMPLATE,
)
from .enums import CoverFormat, DownloadMode, RemuxMode
from ..utils import CustomStringFormatter, async_subprocess
from .constants import ILLEGAL_CHAR_REPLACEMENT, ILLEGAL_CHARS_RE, TEMP_PATH_TEMPLATE
from .enums import DownloadMode, RemuxMode
from .hardcoded_wvd import HARDCODED_WVD
class AppleMusicBaseDownloader:
def __init__(
self,
apple_music_api: AppleMusicApi,
output_path: str = "./Apple Music",
temp_path: str = ".",
wvd_path: str = None,
@@ -41,14 +29,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 +47,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 +58,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 +76,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]
@@ -121,22 +105,6 @@ class AppleMusicBaseDownloader:
) -> bool:
return bool(media_metadata["attributes"].get("playParams"))
async def get_cover_file_extension(self, cover_url_template: str) -> str | None:
if self.cover_format != CoverFormat.RAW:
return f".{self.cover_format.value}"
cover_url = self.get_cover_url(cover_url_template)
cover_bytes = await self.get_cover_bytes(cover_url)
if cover_bytes is None:
return None
image_obj = Image.open(BytesIO(self.get_cover_bytes(cover_url)))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(
image_format,
f".{image_format.lower()}",
)
def get_playlist_tags(
self,
playlist_metadata: dict,
@@ -169,112 +137,98 @@ class AppleMusicBaseDownloader:
/ (f"{media_id}_{file_tag}" + file_extension)
)
@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
def get_sanitized_string(self, dirty_string: str, is_folder: bool) -> str:
dirty_string = re.sub(
def sanitize_string(
self,
dirty_string: str,
file_ext: str = None,
) -> str:
sanitized_string = re.sub(
ILLEGAL_CHARS_RE,
ILLEGAL_CHAR_REPLACEMENT,
dirty_string,
)
if is_folder:
dirty_string = dirty_string[: self.truncate]
if dirty_string.endswith("."):
dirty_string = dirty_string[:-1] + ILLEGAL_CHAR_REPLACEMENT
if file_ext is None:
sanitized_string = sanitized_string[: self.truncate]
if sanitized_string.endswith("."):
sanitized_string = sanitized_string[:-1] + ILLEGAL_CHAR_REPLACEMENT
else:
if self.truncate is not None:
dirty_string = dirty_string[: self.truncate - 4]
return dirty_string.strip()
sanitized_string = sanitized_string[: self.truncate - len(file_ext)]
sanitized_string += file_ext
return sanitized_string.strip()
def get_final_path(
self,
tags: MediaTags,
file_extension: str,
playlist_tags: PlaylistTags,
playlist_tags: PlaylistTags | None,
) -> str:
if tags.album is not None:
template_folder = (
if tags.album:
template_folder_parts = (
self.compilation_folder_template.split("/")
if tags.compilation
else self.album_folder_template.split("/")
)
template_file = (
self.multi_disc_folder_template.split("/")
if tags.disc_total > 1
else self.single_disc_folder_template.split("/")
else:
template_folder_parts = self.no_album_folder_template.split("/")
if tags.album:
template_file_parts = (
self.multi_disc_file_template.split("/")
if isinstance(tags.disc_total, int) and tags.disc_total > 1
else self.single_disc_file_template.split("/")
)
else:
template_folder = self.no_album_folder_template.split("/")
template_file = self.no_album_file_template.split("/")
template_file_parts = self.no_album_file_template.split("/")
template_final = template_folder + template_file
template_parts = template_folder_parts + template_file_parts
formatted_parts = []
tags_dict = tags.__dict__.copy()
if playlist_tags:
tags_dict.update(playlist_tags.__dict__)
return str(
Path(
self.output_path,
*[
self.get_sanitized_string(i.format(**tags_dict), True)
for i in template_final[0:-1]
],
(
self.get_sanitized_string(
template_final[-1].format(**tags_dict), False
)
+ file_extension
for i, part in enumerate(template_parts):
is_folder = i < len(template_parts) - 1
formatted_part = CustomStringFormatter().format(
part,
album=(tags.album, "Unknown Album"),
album_artist=(tags.album_artist, "Unknown Artist"),
album_id=(tags.album_id, "Unknown Album ID"),
artist=(tags.artist, "Unknown Artist"),
artist_id=(tags.artist_id, "Unknown Artist ID"),
composer=(tags.composer, "Unknown Composer"),
composer_id=(tags.composer_id, "Unknown Composer ID"),
date=(tags.date, "Unknown Date"),
disc=(tags.disc, ""),
disc_total=(tags.disc_total, ""),
media_type=(tags.media_type, "Unknown Media Type"),
playlist_artist=(
(playlist_tags.playlist_artist if playlist_tags else None),
"Unknown Playlist Artist",
),
playlist_id=(
(playlist_tags.playlist_id if playlist_tags else None),
"Unknown Playlist ID",
),
playlist_title=(
(playlist_tags.playlist_title if playlist_tags else None),
"Unknown Playlist Title",
),
playlist_track=(
(playlist_tags.playlist_track if playlist_tags else None),
"",
),
title=(tags.title, "Unknown Title"),
title_id=(tags.title_id, "Unknown Title ID"),
track=(tags.track, ""),
track_total=(tags.track_total, ""),
)
)
sanitized_formatted_part = self.sanitize_string(
formatted_part,
file_extension if not is_folder else None,
)
formatted_parts.append(sanitized_formatted_part)
def get_cover_url_template(self, metadata: dict) -> str:
if self.cover_format == CoverFormat.RAW:
return self._get_raw_cover_url(metadata["attributes"]["artwork"]["url"])
return metadata["attributes"]["artwork"]["url"]
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
cover_url_template,
),
)
def get_cover_url(self, cover_url_template: str) -> str:
return self.format_cover_url(
cover_url_template,
self.cover_size,
self.cover_format.value,
)
def format_cover_url(
self,
cover_url_template: str,
cover_size: int,
cover_format: str,
) -> str:
return re.sub(
r"\{w\}x\{h\}([a-z]{2})\.jpg",
(
f"{cover_size}x{cover_size}bb.{cover_format}"
if self.cover_format != CoverFormat.RAW
else ""
),
cover_url_template,
)
return str(Path(self.output_path, *formatted_parts))
async def download_stream(self, stream_url: str, download_path: str):
if self.download_mode == DownloadMode.YTDLP:
@@ -331,7 +285,8 @@ class AppleMusicBaseDownloader:
self,
media_path: Path,
tags: MediaTags,
cover_url_template: str,
cover_bytes: bytes | None,
extra_tags: dict | None = None,
):
exclude_tags = self.exclude_tags or []
@@ -343,25 +298,52 @@ class AppleMusicBaseDownloader:
}
)
mp4_tags = filtered_tags.as_mp4_tags(self.date_tag_template)
skip_tagging = "all" in exclude_tags
await asyncio.to_thread(
self.apply_mp4_tags,
media_path,
mp4_tags,
cover_bytes,
skip_tagging,
extra_tags,
)
def apply_mp4_tags(
self,
media_path: Path,
tags: dict,
cover_bytes: bytes | None,
skip_tagging: bool,
extra_tags: dict | None,
):
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)
if extra_tags:
mp4.update(extra_tags)
mp4.save()
async def _apply_cover(
self,
mp4: MP4,
cover_url_template: str,
cover_bytes: bytes | None,
) -> None:
cover_url = self.get_cover_url(cover_url_template)
cover_bytes = await self.get_cover_bytes(cover_url)
if cover_bytes is None:
return
@@ -392,24 +374,26 @@ class AppleMusicBaseDownloader:
self,
tags: PlaylistTags,
) -> str:
template_file = self.playlist_file_template.split("/")
tags_dict = tags.__dict__.copy()
template_file_parts = self.playlist_file_template.split("/")
formatted_parts = []
return str(
Path(
self.output_path,
*[
self.get_sanitized_string(i.format(**tags_dict), True)
for i in template_file[0:-1]
],
*[
self.get_sanitized_string(
template_file[-1].format(**tags_dict), False
)
+ ".m3u8"
],
for i, part in enumerate(template_file_parts):
is_folder = i < len(template_file_parts) - 1
formatted_part = CustomStringFormatter().format(
part,
playlist_artist=(tags.playlist_artist, "Unknown Playlist Artist"),
playlist_id=(tags.playlist_id, "Unknown Playlist ID"),
playlist_title=(tags.playlist_title, "Unknown Playlist Title"),
playlist_track=(tags.playlist_track, ""),
)
)
file_ext = None if is_folder else ".m3u8"
sanitized_formatted_part = self.sanitize_string(
formatted_part,
file_ext,
)
formatted_parts.append(sanitized_formatted_part)
return str(Path(self.output_path, *formatted_parts))
def update_playlist_file(
self,
+45 -45
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,
@@ -152,46 +146,43 @@ class AppleMusicMusicVideoDownloader:
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,17 +195,25 @@ 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.interface.get_cover_url_template(
music_video_metadata,
self.cover_format,
)
cover_file_extension = await self.downloader.get_cover_file_extension(
download_item.cover_url = self.interface.get_cover_url(
download_item.cover_url_template,
self.cover_size,
self.cover_format,
)
cover_file_extension = await self.interface.get_cover_file_extension(
download_item.cover_url,
self.cover_format,
)
if cover_file_extension:
download_item.cover_path = self.get_cover_path(
@@ -228,35 +227,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,8 +271,9 @@ class AppleMusicMusicVideoDownloader:
download_item.decryption_key,
)
await self.downloader.apply_tags(
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
download_item.cover_url_template,
cover_bytes,
)
+99 -55
View File
@@ -10,26 +10,26 @@ 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,
use_album_date: bool = False,
fetch_extra_tags: 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)
self.use_album_date = use_album_date
self.fetch_extra_tags = fetch_extra_tags
async def get_download_item(
self,
@@ -39,30 +39,36 @@ class AppleMusicSongDownloader:
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 = await self.interface.get_tags(
webplayback,
download_item.lyrics.unsynced if download_item.lyrics else None,
self.use_album_date,
)
if self.fetch_extra_tags:
download_item.extra_tags = await self.interface.get_extra_tags(
song_metadata,
)
if playlist_metadata:
download_item.playlist_tags = self.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,49 +81,57 @@ 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.interface.get_cover_url_template(
song_metadata,
self.cover_format,
)
download_item.cover_url = self.interface.get_cover_url(
download_item.cover_url_template,
self.cover_size,
self.cover_format,
)
download_item.random_uuid = self.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.cover_url_template,
download_item.random_uuid = self.get_random_uuid()
if download_item.stream_info and download_item.stream_info.file_format:
download_item.staged_path = self.get_temp_path(
song_id,
download_item.random_uuid,
"staged",
"." + download_item.stream_info.file_format.value,
)
else:
download_item.staged_path = None
cover_file_extension = await self.interface.get_cover_file_extension(
download_item.cover_url,
self.cover_format,
)
if cover_file_extension:
download_item.cover_path = self.get_cover_path(
@@ -143,7 +157,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 +166,7 @@ class AppleMusicSongDownloader:
"-keep-utc",
"-new",
output_path,
silent=self.downloader.silent,
silent=self.silent,
)
async def remux_ffmpeg(
@@ -170,7 +184,7 @@ class AppleMusicSongDownloader:
key = []
await async_subprocess(
self.downloader.full_ffmpeg_path,
self.full_ffmpeg_path,
"-loglevel",
"error",
"-y",
@@ -182,7 +196,7 @@ class AppleMusicSongDownloader:
"-movflags",
"+faststart",
output_path,
silent=self.downloader.silent,
silent=self.silent,
)
async def decrypt_mp4decrypt(
@@ -207,11 +221,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 +252,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 +278,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 +311,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,10 +334,14 @@ 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(
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
download_item.cover_url_template,
cover_bytes,
download_item.extra_tags,
)
+39 -21
View File
@@ -6,61 +6,77 @@ 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.interface.get_cover_url_template(
uploaded_video_metadata,
self.cover_format,
)
cover_file_extension = await self.downloader.get_cover_file_extension(
download_item.cover_url = self.interface.get_cover_url(
download_item.cover_url_template,
self.cover_size,
self.cover_format,
)
cover_file_extension = await self.interface.get_cover_file_extension(
download_item.cover_url,
self.cover_format,
)
if cover_file_extension:
download_item.cover_path = self.get_cover_path(
@@ -74,12 +90,14 @@ 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(
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
await self.apply_tags(
download_item.staged_path,
download_item.media_tags,
download_item.cover_url_template,
cover_bytes,
)
-6
View File
@@ -11,12 +11,6 @@ class RemuxMode(Enum):
MP4BOX = "mp4box"
class CoverFormat(Enum):
JPG = "jpg"
PNG = "png"
RAW = "raw"
class RemuxFormatMusicVideo(Enum):
M4V = "m4v"
MP4 = "mp4"
+26 -13
View File
@@ -1,19 +1,32 @@
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")
class UnsupportedMediaType(GamdlError):
def __init__(self, media_type: str):
super().__init__(f"Unsupported media type: {media_type}")
+6
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from typing import Any
from ..interface.types import (
DecryptionKeyAv,
@@ -12,18 +13,23 @@ 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
extra_tags: dict = None
playlist_tags: PlaylistTags = None
stream_info: StreamInfoAv = None
decryption_key: DecryptionKeyAv = None
cover_url_template: str = None
cover_url: str = None
staged_path: str = None
final_path: str = None
playlist_file_path: str = None
synced_lyrics_path: str = None
cover_path: str = None
flat_filter_result: Any = None
error: Exception = None
@dataclass
+5
View File
@@ -55,3 +55,8 @@ UPLOADED_VIDEO_QUALITY_RANK = [
"sd480pVideo",
"provisionalUploadVideo",
]
IMAGE_FILE_EXTENSION_MAP = {
"jpeg": ".jpg",
"tiff": ".tif",
}
+6
View File
@@ -87,3 +87,9 @@ class MusicVideoResolution(Enum):
class UploadedVideoQuality(Enum):
BEST = "best"
ASK = "ask"
class CoverFormat(Enum):
JPG = "jpg"
PNG = "png"
RAW = "raw"
+98 -2
View File
@@ -1,11 +1,19 @@
import asyncio
import base64
import datetime
import logging
import re
from io import BytesIO
from async_lru import alru_cache
from PIL import Image
from pywidevine import PSSH, Cdm
from ..api.apple_music_api import AppleMusicApi
from ..api.itunes_api import ItunesApi
from ..utils import get_response
from .constants import IMAGE_FILE_EXTENSION_MAP
from .enums import CoverFormat
from .types import DecryptionKey
logger = logging.getLogger(__name__)
@@ -41,7 +49,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 +59,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"
)
@@ -63,3 +73,89 @@ class AppleMusicInterface:
logger.debug(f"Decryption key: {decryption_key}")
return decryption_key
def get_cover_url_template(self, metadata: dict, cover_format: CoverFormat) -> str:
if cover_format == CoverFormat.RAW:
cover_url_template = self._get_raw_cover_url(
metadata["attributes"]["artwork"]["url"]
)
cover_url_template = metadata["attributes"]["artwork"]["url"]
logger.debug(f"Cover URL template: {cover_url_template}")
return cover_url_template
def _get_raw_cover_url(self, cover_url_template: str) -> str:
return re.sub(
r"image/thumb/",
"",
re.sub(
r"is1-ssl",
"a1",
cover_url_template,
),
)
def get_cover_url(
self,
cover_url_template: str,
cover_size: int,
cover_format: CoverFormat,
) -> str:
cover_url = re.sub(
r"\{w\}x\{h\}([a-z]{2})\.jpg",
(
f"{cover_size}x{cover_size}bb.{cover_format.value}"
if cover_format != CoverFormat.RAW
else ""
),
cover_url_template,
)
logger.debug(f"Cover URL: {cover_url}")
return cover_url
@alru_cache()
async def get_cover_file_extension(
self,
cover_url: str,
cover_format: CoverFormat,
) -> str | None:
if cover_format != CoverFormat.RAW:
return f".{cover_format.value}"
cover_url = self.get_cover_url(cover_url)
cover_bytes = await self.get_cover_bytes(cover_url)
if cover_bytes is None:
return None
image_obj = Image.open(BytesIO(self.get_cover_bytes(cover_url)))
image_format = image_obj.format.lower()
return IMAGE_FILE_EXTENSION_MAP.get(
image_format,
f".{image_format.lower()}",
)
@alru_cache()
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
response = await get_response(cover_url, {200, 404})
if response.status_code == 200:
return response.content
return None
@alru_cache()
async def get_media_date(
self,
media_id: str,
) -> datetime.datetime | None:
lookup_result = await self.itunes_api.get_lookup_result(media_id)
if not lookup_result["results"]:
return None
release_date = lookup_result["results"][0].get("releaseDate")
if not release_date:
return None
parsed_date = self.parse_date(release_date)
logger.debug(f"Parsed media date: {parsed_date}")
return parsed_date
+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,
+54 -28
View File
@@ -1,5 +1,7 @@
import asyncio
import base64
import datetime
import io
import json
import logging
import re
@@ -9,10 +11,11 @@ from xml.etree import ElementTree
import m3u8
from InquirerPy import inquirer
from InquirerPy.base.control import Choice
from mutagen.mp4 import MP4
from pywidevine import PSSH, Cdm
from pywidevine.license_protocol_pb2 import WidevinePsshData
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 +32,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 +49,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 +109,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
),
)
@@ -168,10 +170,11 @@ class AppleMusicSongInterface:
return f"[{timestamp.strftime('%M:%S.%f')[:-4]}]{text}"
def get_tags(
async def get_tags(
self,
webplayback: dict,
lyrics: str | None = None,
use_album_date: bool = False,
) -> MediaTags:
webplayback_metadata = webplayback["songList"][0]["assets"][0]["metadata"]
@@ -194,9 +197,13 @@ class AppleMusicSongInterface:
composer_sort=webplayback_metadata.get("sort-composer"),
copyright=webplayback_metadata.get("copyright"),
date=(
self.interface.parse_date(webplayback_metadata["releaseDate"])
if webplayback_metadata.get("releaseDate")
else None
await self.get_media_date(webplayback_metadata["playlistId"])
if use_album_date
else (
self.parse_date(webplayback_metadata["releaseDate"])
if webplayback_metadata.get("releaseDate")
else None
)
),
disc=webplayback_metadata["discNumber"],
disc_total=webplayback_metadata["discCount"],
@@ -229,7 +236,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 +280,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 +391,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 +421,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,9 +457,26 @@ 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,
)
)
async def get_extra_tags(
self,
song_metadata: dict,
) -> dict:
previews = song_metadata["attributes"].get("previews", [])
if not previews:
return {}
preview_url = previews[0]["url"]
preview_response = await get_response(preview_url)
preview_bytes = preview_response.content
preview_tags = dict(MP4(io.BytesIO(preview_bytes)).tags)
logger.debug(f"Extra tags: {preview_tags.keys()}")
return preview_tags
+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
+44 -18
View File
@@ -1,7 +1,8 @@
import json
import typing
import subprocess
import asyncio
import json
import string
import subprocess
import typing
import httpx
@@ -20,11 +21,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 +52,46 @@ 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
class CustomStringFormatter(string.Formatter):
def format_field(self, value: typing.Any, format_spec: str) -> str:
if isinstance(value, tuple) and len(value) == 2:
actual_value, fallback_value = value
if actual_value is None:
return fallback_value
try:
return super().format_field(actual_value, format_spec)
except Exception:
return fallback_value
return super().format_field(value, format_spec)
+10 -2
View File
@@ -1,13 +1,15 @@
[project]
name = "gamdl"
version = "2.7"
version = "2.8.3"
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
readme = "README.md"
license = { text = "MIT" }
license = "MIT"
requires-python = ">=3.10"
dependencies = [
"async-lru>=2.0.5",
"click>=8.3.0",
"colorama>=0.4.6",
"dataclass-click>=1.0.4",
"httpx>=0.28.1",
"inquirerpy>=0.3.4",
"m3u8>=6.0.0",
@@ -17,5 +19,11 @@ dependencies = [
"yt-dlp>=2025.10.22",
]
[project.urls]
Repository = "https://github.com/glomatico/gamdl"
[project.scripts]
gamdl = "gamdl.cli.cli:main"
[tool.setuptools]
packages = ["gamdl"]
Generated
+17 -1
View File
@@ -188,6 +188,18 @@ version = "2.8.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/2c/66bab4fef920ef8caa3e180ea601475b2cbbe196255b18f1c58215940607/construct-2.8.8.tar.gz", hash = "sha256:1b84b8147f6fd15bcf64b737c3e8ac5100811ad80c830cb4b2545140511c4157", size = 717694, upload-time = "2016-10-20T22:29:12.563Z" }
[[package]]
name = "dataclass-click"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/82/5b6035efd90621771fa039960eab3e1ec7ff2a8625033272856843e8bd27/dataclass_click-1.0.4.tar.gz", hash = "sha256:10e7de638dd9e68ae9abd5086f61d8ddee42b1873a70f5fd9fd2167856afac11", size = 7580, upload-time = "2025-10-10T21:11:31.956Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/dc/38a94a2eb5f756724a6dc87a7aea38f7b747fe7b2e9daabc34a65e6cd9ac/dataclass_click-1.0.4-py3-none-any.whl", hash = "sha256:a225d30c04e4abbdba411cc3d5ec0a2ea829e1dca6500afe5f87cc243e5ead72", size = 8553, upload-time = "2025-10-10T21:11:30.514Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.0"
@@ -202,11 +214,13 @@ wheels = [
[[package]]
name = "gamdl"
version = "2.7"
version = "2.8.3"
source = { virtual = "." }
dependencies = [
{ name = "async-lru" },
{ name = "click" },
{ name = "colorama" },
{ name = "dataclass-click" },
{ name = "httpx" },
{ name = "inquirerpy" },
{ name = "m3u8" },
@@ -220,6 +234,8 @@ dependencies = [
requires-dist = [
{ name = "async-lru", specifier = ">=2.0.5" },
{ name = "click", specifier = ">=8.3.0" },
{ name = "colorama", specifier = ">=0.4.6" },
{ name = "dataclass-click", specifier = ">=1.0.4" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "inquirerpy", specifier = ">=0.3.4" },
{ name = "m3u8", specifier = ">=6.0.0" },