mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 20:25:13 +03:00
Compare commits
250 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7a83bab50 | |||
| 4aa70733d6 | |||
| 7063900dd4 | |||
| ff5298c0ae | |||
| 3c54368f03 | |||
| 905bbfd5ca | |||
| d84bc2c695 | |||
| 82ab9827eb | |||
| ff5dc4f20c | |||
| a99707666b | |||
| 91db55adc3 | |||
| ae8d4a27aa | |||
| cfc4673082 | |||
| 64a20f030a | |||
| c4536963f8 | |||
| 0b318156a4 | |||
| 30b3f36905 | |||
| 9b76ab90a7 | |||
| f3dfd3d9d8 | |||
| 95c6e6dce7 | |||
| 2fd7ad9334 | |||
| 97e8fd2223 | |||
| 119a39c4fe | |||
| f9d62ee84b | |||
| 939e9459ef | |||
| de76ce898e | |||
| 5bbe87500a | |||
| 61ea24bfdd | |||
| b5837bdca5 | |||
| b21a9cc35b | |||
| fe6fe54880 | |||
| 56748797eb | |||
| 9d504a34b0 | |||
| b59d7b9a73 | |||
| d3b13ebe26 | |||
| c2bfe4f2f3 | |||
| 178dc8822e | |||
| 2a966f178f | |||
| 4cb771a925 | |||
| 102dce2b75 | |||
| 27630b5657 | |||
| 8335af0f79 | |||
| e3ce405a41 | |||
| c5e001fda5 | |||
| eba97c8344 | |||
| 0413d133b5 | |||
| e330e11d82 | |||
| bebfcb02d8 | |||
| 29f68f6bc4 | |||
| e77c6b24b4 | |||
| ba315dcb95 | |||
| 4187fad734 | |||
| f36edf4bbd | |||
| 50478d427e | |||
| 45461007a9 | |||
| 79a03d4f4c | |||
| beb508529a | |||
| 87cf8c7789 | |||
| 9e3f740eec | |||
| 7281f5c949 | |||
| d32781b23f | |||
| 5f2c74399e | |||
| 6b67c435fa | |||
| 240ba7d4de | |||
| 02c19963b4 | |||
| 2e2fef1426 | |||
| ae3b2e1c6d | |||
| 6516855be9 | |||
| 77cbb8a7ca | |||
| 18bc6595a9 | |||
| da2c3d5f1e | |||
| abe364aad1 | |||
| 10b529d6fd | |||
| afe42848d0 | |||
| b3b5e6d1b2 | |||
| 9f86c7436d | |||
| 74a26d0342 | |||
| 37895dea1c | |||
| 04396a7f3f | |||
| bde49305c9 | |||
| b0c3b4630d | |||
| fd30ab861b | |||
| b1827e8d1b | |||
| fe020442b1 | |||
| 87b8492b4f | |||
| f961ade8d8 | |||
| 471a2e85ac | |||
| a17b1296d8 | |||
| 22628c4c53 | |||
| 23a5be37b1 | |||
| 9aa7a2e199 | |||
| 31d07172a6 | |||
| fbe0167f0e | |||
| 1d621568a0 | |||
| fa31649d76 | |||
| 16d8dc925a | |||
| 46d1ec11dc | |||
| f68e76ce8b | |||
| 42df1f7f5e | |||
| d11e937c6a | |||
| a7c8ff4297 | |||
| 5332e0e1c0 | |||
| b8ea1d0039 | |||
| 4de0e3d1f8 | |||
| c770ff361f | |||
| d6afb680be | |||
| b15f404849 | |||
| 072d71caaf | |||
| 7e132c27de | |||
| 073f70afa7 | |||
| a49430018a | |||
| f0450b93c7 | |||
| 9b701e8ee8 | |||
| f4e6069e69 | |||
| 841b1edb64 | |||
| ef4b34f3d2 | |||
| 98980fc130 | |||
| 6c84651770 | |||
| f9d3d0a97e | |||
| 9a879c0857 | |||
| d0ab35383b | |||
| b14004f3e3 | |||
| a6e409d98d | |||
| d1c9aea874 | |||
| 8c110b4fb9 | |||
| e1c8cb51ad | |||
| 52324d519c | |||
| 057315524f | |||
| 446636166e | |||
| 7199cac179 | |||
| be4f30cb54 | |||
| 83ca91e91c | |||
| 6ed596ca42 | |||
| 414ce749d6 | |||
| 17863b500a | |||
| 5e48032f34 | |||
| e2ed443253 | |||
| ade78ad7b3 | |||
| 054f636434 | |||
| bf9c74d9d8 | |||
| 3c48618e84 | |||
| c940ee2f47 | |||
| 7f56dfd0c8 | |||
| 7c3112421d | |||
| 55ce7555a9 | |||
| 9c4adbb2c1 | |||
| 1591f0daf2 | |||
| 25d028bea4 | |||
| ebc28a019e | |||
| 690df6e9d7 | |||
| 8039c7c86f | |||
| f67ba37d19 | |||
| 59f247a90f | |||
| 181bdb198d | |||
| 1945342adc | |||
| f19ef4d6dd | |||
| 1ceb7fcf46 | |||
| 23ed14ca04 | |||
| 3e3939d0ee | |||
| 780261a9c8 | |||
| 80cb80e9a2 | |||
| f3b7adaad3 | |||
| fe6a6e308d | |||
| b08bf98759 | |||
| 37c857b503 | |||
| 4693ba69c9 | |||
| 9212319d3b | |||
| e54f318c36 | |||
| b1e40299ca | |||
| ba86825068 | |||
| b5f08753b8 | |||
| d4bf75c0d1 | |||
| e998ce1a2e | |||
| 5285ca0cfa | |||
| f3927b8e6d | |||
| 40b7ce05d3 | |||
| 8cd01e7964 | |||
| f769c6b686 | |||
| ea7356e7c4 | |||
| f3d8242110 | |||
| faf3bb3a20 | |||
| 24c3ce8a02 | |||
| 65eb8c0fb6 | |||
| f90be057d6 | |||
| 76cc80cba8 | |||
| 7a7c1adb22 | |||
| 200e392fad | |||
| 1083957303 | |||
| ae6bed11af | |||
| 7da83866cf | |||
| 273b171398 | |||
| 2913d96b70 | |||
| a332516056 | |||
| c636e4be33 | |||
| 1841a988e2 | |||
| 8cdaa127d7 | |||
| c31a6eee8e | |||
| 00d301c23d | |||
| f05aa579d3 | |||
| 7e642ab2f3 | |||
| c34f49faae | |||
| 78c3da5b8c | |||
| 00410aeb77 | |||
| 4211ab6f8c | |||
| 599c9140db | |||
| 73ab79beea | |||
| 2dfed33fe2 | |||
| 4eb764af17 | |||
| 6cdccf1f4f | |||
| a999271715 | |||
| 633674f45e | |||
| ceeef6b352 | |||
| 8aa172185a | |||
| bdbaf7ca05 | |||
| a9e1e02ebb | |||
| 85619a3672 | |||
| 15c1cc45f5 | |||
| b86e938185 | |||
| be4596798a | |||
| da8e49bd68 | |||
| 03c3b0e788 | |||
| 3aca011b7d | |||
| dfa38c6736 | |||
| 48a8c940e1 | |||
| e80c776835 | |||
| 36e85098e5 | |||
| 7610768723 | |||
| 9afe027f5d | |||
| 4c5c43844a | |||
| 025c89d85a | |||
| f8d1036c37 | |||
| 0d8e6c4626 | |||
| 5aff11bcae | |||
| b5ce18ef26 | |||
| 70346171b1 | |||
| 4a63070489 | |||
| cb60eee694 | |||
| 955f649779 | |||
| c833f24fe2 | |||
| bc76032532 | |||
| 42f782faa5 | |||
| 862a150c44 | |||
| 4cfb626d00 | |||
| fdab6481ea | |||
| 9eff34390b | |||
| f2c1961697 | |||
| fff227522f | |||
| b7c813571e | |||
| 2c91982ae0 | |||
| 04f847a9bf |
@@ -26,32 +26,44 @@ A command-line app for downloading Apple Music songs, music videos and post vide
|
||||
- **Apple Music Cookies** - Export your browser cookies in Netscape format while logged in with an active subscription at the Apple Music website:
|
||||
- **Firefox**: [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt)
|
||||
- **Chromium**: [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
||||
- **FFmpeg** - Must be in your system PATH
|
||||
- **Windows**: [AnimMouse's FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases)
|
||||
- **Linux**: [John Van Sickle's FFmpeg Builds](https://johnvansickle.com/ffmpeg/)
|
||||
|
||||
### Optional
|
||||
### Dependencies
|
||||
|
||||
Add these tools to your system PATH for additional features:
|
||||
Add these tools to your system PATH or specify their paths via command-line arguments or the config file. The tools needed depend on which audio quality, video format, and download mode you want. Use the table below to find the required tools for your use case:
|
||||
|
||||
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required for `mp4box` remux mode, music videos, and experimental codecs
|
||||
- **[MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)** - Required for `mp4box` remux mode
|
||||
- **[N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)** - Required for `nm3u8dlre` download mode, which is faster than the default downloader
|
||||
| Use Case | Configuration | Required Tools |
|
||||
|---|---|---|
|
||||
| **Songs in Legacy Codecs** | `song_codec_priority: aac-legacy\|aac-he-legacy` | None |
|
||||
| **Songs in Non Legacy Codecs** | `song_codec_priority: aac\|aac-he\|aac-binaural\|aac-downmix\|aac-he-binaural\|aac-he-downmix\|atmos\|ac3`<br/>`use_wrapper: true` | Wrapper |
|
||||
| **Music Videos** | `music_video_remux_mode: ffmpeg` | FFmpeg<br/>mp4decrypt |
|
||||
| | `music_video_remux_mode: mp4box` | MP4Box<br/>mp4decrypt |
|
||||
| **Faster Downloads** | `download_mode: nm3u8dlre` | N_m3u8DL-RE |
|
||||
|
||||
#### Tool Reference
|
||||
|
||||
| Tool | Download | Purpose |
|
||||
|---|---|---|
|
||||
| **FFmpeg** | [Windows](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases) / [Linux](https://johnvansickle.com/ffmpeg/) | Required for music video remuxing with FFmpeg mode |
|
||||
| **MP4Box** | [Download](https://gpac.io/downloads/gpac-nightly-builds/) | Alternative for music video remuxing |
|
||||
| **mp4decrypt** | [Download](https://www.bento4.com/downloads/) | Decrypts MP4 files when used with MP4Box |
|
||||
| **N_m3u8DL-RE** | [Download](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest) | Faster download alternative |
|
||||
| **Wrapper** | [Download](https://github.com/WorldObservationLog/wrapper) | For downloading songs in ALAC and other experimental codecs |
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
**Install Gamdl via pipx:**
|
||||
1. **Install Gamdl via pip:**
|
||||
|
||||
[pipx](https://pipx.pypa.io/stable/installation/) is recommended for installing Gamdl to avoid dependency conflicts, but you can also use pip.
|
||||
```bash
|
||||
pip install gamdl
|
||||
```
|
||||
|
||||
```bash
|
||||
pipx install gamdl
|
||||
```
|
||||
2. **Set up the cookies file:**
|
||||
- Place the cookies file in the working directory as `cookies.txt`, or
|
||||
- Specify the path using `--cookies-path` or in the config file
|
||||
|
||||
**Setup cookies:**
|
||||
3. **Optional: Set up tools** (only if you need the functionality)
|
||||
|
||||
1. Place your cookies file in the working directory as `cookies.txt`, or
|
||||
2. Specify the path using `--cookies-path` or in the config file
|
||||
See the [Dependencies](#dependencies) section to determine which tools you need based on your use case, then follow the [Tool Reference](#tool-reference) for download and installation instructions.
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
@@ -67,6 +79,7 @@ gamdl [OPTIONS] URLS...
|
||||
- Music Videos
|
||||
- Artists
|
||||
- Post Videos
|
||||
- Apple Music Classical
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -110,76 +123,85 @@ 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` |
|
||||
| `--artist-auto-select` | Automatically select artist content to download (artist URLs) | - |
|
||||
| `--database-path` | Path to the SQLite database file for registering downloaded media | - |
|
||||
| `--no-config-file`, `-n` | Don't use a config file | `false` |
|
||||
| **Apple Music Options** | | |
|
||||
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
|
||||
| `--wrapper-account-url` | Wrapper account URL | `http://127.0.0.1:30020` |
|
||||
| `--language`, `-l` | Metadata language | `en-US` |
|
||||
| **Output Options** | | |
|
||||
| `--cover-format` | Cover format | `jpg` |
|
||||
| `--cover-size` | Cover size in pixels | `1200` |
|
||||
| `--wvd-path` | .wvd file path | - |
|
||||
| **Song Options** | | |
|
||||
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
|
||||
| `--song-codec-priority` | Comma-separated codec priority | `aac-legacy` |
|
||||
| `--use-album-date` | Use album release date for songs | `false` |
|
||||
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
|
||||
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
|
||||
| **Music Video Options** | | |
|
||||
| `--music-video-resolution` | Max music video resolution | `1080p` |
|
||||
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
|
||||
| `--music-video-remux-mode` | Remux mode | `ffmpeg` |
|
||||
| `--music-video-remux-format` | Music video remux format | `m4v` |
|
||||
| **Post Video Options** | | |
|
||||
| `--uploaded-video-quality` | Post video quality | `best` |
|
||||
| **Download & Path Options** | | |
|
||||
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
|
||||
| `--temp-path` | Temporary directory path | `.` |
|
||||
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
|
||||
| `--mp4decrypt-path` | mp4decrypt executable path | `mp4decrypt` |
|
||||
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
|
||||
| `--mp4box-path` | MP4Box executable path | `MP4Box` |
|
||||
| `--use-wrapper` | Use wrapper for decrypting songs | `false` |
|
||||
| `--wrapper-decrypt-ip` | Wrapper decryption server IP | `127.0.0.1:10020` |
|
||||
| `--download-mode` | Download mode | `ytdlp` |
|
||||
| **Template Options** | | |
|
||||
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
|
||||
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
|
||||
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
|
||||
| `--playlist-folder-template` | Playlist folder template | `Playlists/{playlist_artist}/{playlist_title}` |
|
||||
| `--single-disc-file-template` | Single disc file template | `{track:02d} {title}` |
|
||||
| `--multi-disc-file-template` | Multi disc file template | `{disc}-{track:02d} {title}` |
|
||||
| `--no-album-file-template` | No album file template | `{title}` |
|
||||
| `--playlist-file-template` | Playlist file template | `Playlists/{playlist_artist}/{playlist_title}` |
|
||||
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` | Comma-separated tags to exclude | - |
|
||||
| `--truncate` | Max filename length | - |
|
||||
| **File Output Options** | | |
|
||||
| `--overwrite` | Overwrite existing files | `false` |
|
||||
| `--save-cover`, `-s` | Save cover as separate file | `false` |
|
||||
| `--save-playlist` | Save M3U8 playlist file | `false` |
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
@@ -189,6 +211,9 @@ Use these variables in folder/file templates or `--exclude-tags`:
|
||||
|
||||
- `ytdlp`, `nm3u8dlre`
|
||||
|
||||
> [!NOTE]
|
||||
> - **yt-dlp is only used as a file download library**. Media is still fetched directly from Apple Music's servers, and yt-dlp is only responsible for handling the file download process.
|
||||
|
||||
### Remux Mode
|
||||
|
||||
- `ffmpeg`
|
||||
@@ -221,7 +246,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,12 +275,33 @@ 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
|
||||
|
||||
### Artist Auto-Select Options
|
||||
|
||||
- `main-albums`
|
||||
- `compilation-albums`
|
||||
- `live-albums`
|
||||
- `singles-eps`
|
||||
- `all-albums`
|
||||
- `top-songs`
|
||||
- `music-videos`
|
||||
|
||||
## ⚙️ Wrapper
|
||||
|
||||
Use the [wrapper](https://github.com/WorldObservationLog/wrapper) to download songs in ALAC and other experimental codecs without API limitations. Cookies are not required when using the wrapper.
|
||||
|
||||
### Setup Instructions
|
||||
|
||||
1. **Start the wrapper server** - Run the wrapper server
|
||||
2. **Enable wrapper in Gamdl** - Use `--use-wrapper` flag or set `use_wrapper = true` in config
|
||||
3. **Run Gamdl** - Download as usual with the wrapper enabled
|
||||
|
||||
## 🐍 Embedding
|
||||
|
||||
Use Gamdl as a library in your Python projects:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
from gamdl.api import AppleMusicApi
|
||||
from gamdl.downloader import (
|
||||
AppleMusicBaseDownloader,
|
||||
@@ -264,44 +310,80 @@ from gamdl.downloader import (
|
||||
AppleMusicSongDownloader,
|
||||
AppleMusicUploadedVideoDownloader,
|
||||
)
|
||||
from gamdl.interface import (
|
||||
AppleMusicBaseInterface,
|
||||
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
|
||||
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
|
||||
cookies_path="cookies.txt",
|
||||
)
|
||||
|
||||
# Initialize downloaders
|
||||
base_downloader = AppleMusicBaseDownloader(apple_music_api=api)
|
||||
base_downloader.setup()
|
||||
# Check subscription
|
||||
if not apple_music_api.active_subscription:
|
||||
print("No active Apple Music subscription")
|
||||
return
|
||||
|
||||
song_downloader = AppleMusicSongDownloader(base_downloader)
|
||||
song_downloader.setup()
|
||||
# Create base interface
|
||||
base_interface = await AppleMusicBaseInterface.create(
|
||||
apple_music_api=apple_music_api,
|
||||
)
|
||||
|
||||
music_video_downloader = AppleMusicMusicVideoDownloader(base_downloader)
|
||||
music_video_downloader.setup()
|
||||
# Create specialized interfaces
|
||||
song_interface = AppleMusicSongInterface(
|
||||
base=base_interface,
|
||||
)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(
|
||||
base=base_interface,
|
||||
)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(
|
||||
base=base_interface,
|
||||
)
|
||||
|
||||
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(base_downloader)
|
||||
uploaded_video_downloader.setup()
|
||||
# Create main interface
|
||||
interface = AppleMusicInterface(
|
||||
song=song_interface,
|
||||
music_video=music_video_interface,
|
||||
uploaded_video=uploaded_video_interface,
|
||||
)
|
||||
|
||||
# Create base downloader
|
||||
base_downloader = AppleMusicBaseDownloader(
|
||||
interface=interface,
|
||||
)
|
||||
|
||||
# Create specialized downloaders
|
||||
song_downloader = AppleMusicSongDownloader(base=base_downloader)
|
||||
music_video_downloader = AppleMusicMusicVideoDownloader(
|
||||
base=base_downloader,
|
||||
)
|
||||
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(base=base_downloader)
|
||||
|
||||
# Create main downloader
|
||||
downloader = AppleMusicDownloader(
|
||||
base_downloader,
|
||||
song_downloader,
|
||||
music_video_downloader,
|
||||
uploaded_video_downloader,
|
||||
song=song_downloader,
|
||||
music_video=music_video_downloader,
|
||||
uploaded_video=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"
|
||||
)
|
||||
# Download from URL
|
||||
url = "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
|
||||
|
||||
download_queue = []
|
||||
async for media in downloader.get_download_item_from_url(url):
|
||||
download_queue.append(media)
|
||||
|
||||
if url_info:
|
||||
download_queue = await downloader.get_download_queue(url_info)
|
||||
if download_queue:
|
||||
for download_item in download_queue:
|
||||
await downloader.download(download_item)
|
||||
for download_item in download_queue:
|
||||
try:
|
||||
await downloader.download(download_item)
|
||||
except Exception as e:
|
||||
print(f"Error downloading: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -314,4 +396,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
@@ -1 +1 @@
|
||||
__version__ = "2.7"
|
||||
__version__ = "3.0"
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
from gamdl.cli.cli import main
|
||||
from .cli.cli import main
|
||||
|
||||
main()
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from .apple_music_api import AppleMusicApi
|
||||
from .itunes_api import ItunesApi
|
||||
from .apple_music import AppleMusicApi
|
||||
from .exceptions import *
|
||||
from .itunes import ItunesApi
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
import re
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from httpx_retries import Retry, RetryTransport
|
||||
|
||||
from .constants import (
|
||||
APPLE_MUSIC_ACCOUNT_INFO_API_URI,
|
||||
APPLE_MUSIC_ALBUM_API_URI,
|
||||
APPLE_MUSIC_AMP_API_URL,
|
||||
APPLE_MUSIC_ARTIST_API_URI,
|
||||
APPLE_MUSIC_COOKIE_DOMAIN,
|
||||
APPLE_MUSIC_HOMEPAGE_URL,
|
||||
APPLE_MUSIC_LIBRARY_ALBUM_API_URI,
|
||||
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI,
|
||||
APPLE_MUSIC_LICENSE_API_URL,
|
||||
APPLE_MUSIC_MUSIC_VIDEO_API_URI,
|
||||
APPLE_MUSIC_PLAYLIST_API_URI,
|
||||
APPLE_MUSIC_SEARCH_API_URI,
|
||||
APPLE_MUSIC_SONG_API_URI,
|
||||
APPLE_MUSIC_UPLOADED_VIDEO_API_URL,
|
||||
APPLE_MUSIC_WEBPLAYBACK_API_URL,
|
||||
)
|
||||
from .exceptions import GamdlApiResponseError
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicApi:
|
||||
def __init__(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
token: str,
|
||||
storefront: str,
|
||||
language: str,
|
||||
media_user_token: str | None = None,
|
||||
account_info: dict | None = None,
|
||||
) -> None:
|
||||
self.token = token
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
self.media_user_token = media_user_token
|
||||
self.account_info = account_info
|
||||
self.client = client
|
||||
|
||||
@property
|
||||
def active_subscription(self) -> bool:
|
||||
if not self.account_info:
|
||||
return False
|
||||
|
||||
return (
|
||||
self.account_info.get("meta", {})
|
||||
.get("subscription", {})
|
||||
.get("active", False)
|
||||
)
|
||||
|
||||
@property
|
||||
def account_restrictions(self) -> dict | None:
|
||||
if not self.account_info:
|
||||
return None
|
||||
|
||||
data = self.account_info.get("data", [])
|
||||
if not data:
|
||||
return None
|
||||
return data[0].get("attributes", {}).get("restrictions")
|
||||
|
||||
@staticmethod
|
||||
async def get_token() -> str:
|
||||
log = logger.bind(action="get_token")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
APPLE_MUSIC_HOMEPAGE_URL,
|
||||
follow_redirects=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
home_page = response.text
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching Apple Music homepage",
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
index_js_uri_match = re.search(
|
||||
r"/(assets/index-legacy[~-][^/\"]+\.js)",
|
||||
home_page,
|
||||
)
|
||||
if not index_js_uri_match:
|
||||
raise GamdlApiResponseError(
|
||||
"Error finding index.js URI in Apple Music homepage"
|
||||
)
|
||||
index_js_uri = index_js_uri_match.group(1)
|
||||
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
index_js_page = response.text
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching index.js page",
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
token_match = re.search('(?=eyJh)(.*?)(?=")', index_js_page)
|
||||
if not token_match:
|
||||
raise GamdlApiResponseError("Error finding token in index.js page")
|
||||
token = token_match.group(1)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
async def get_account_info(
|
||||
token: str,
|
||||
media_user_token: str,
|
||||
meta: str = "subscription",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_account_info", meta=meta)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
APPLE_MUSIC_AMP_API_URL + APPLE_MUSIC_ACCOUNT_INFO_API_URI,
|
||||
params={
|
||||
"meta": meta,
|
||||
},
|
||||
headers={
|
||||
"authorization": f"Bearer {token}",
|
||||
"origin": APPLE_MUSIC_HOMEPAGE_URL,
|
||||
"cookie": f"media-user-token={media_user_token}",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
account_info = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching account info",
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
log.debug("success", account_info=account_info)
|
||||
|
||||
return account_info
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
storefront: str | None = "us",
|
||||
language: str = "en-US",
|
||||
token: str | None = None,
|
||||
media_user_token: str | None = None,
|
||||
) -> "AppleMusicApi":
|
||||
token = token or await cls.get_token()
|
||||
account_info = (
|
||||
await cls.get_account_info(token, media_user_token)
|
||||
if media_user_token
|
||||
else None
|
||||
)
|
||||
storefront = (
|
||||
account_info["meta"]["subscription"]["storefront"]
|
||||
if account_info
|
||||
else storefront
|
||||
)
|
||||
if not storefront:
|
||||
raise ValueError(
|
||||
"Storefront must be provided if it cannot be determined from account info"
|
||||
)
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
headers={
|
||||
"authorization": f"Bearer {token}",
|
||||
"origin": APPLE_MUSIC_HOMEPAGE_URL,
|
||||
},
|
||||
transport=RetryTransport(
|
||||
retry=Retry(
|
||||
total=6,
|
||||
backoff_factor=1,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
if media_user_token:
|
||||
client.headers.update(
|
||||
{
|
||||
"cookie": f"media-user-token={media_user_token}",
|
||||
}
|
||||
)
|
||||
|
||||
api = cls(
|
||||
client=client,
|
||||
token=token,
|
||||
storefront=storefront,
|
||||
language=language,
|
||||
media_user_token=media_user_token,
|
||||
account_info=account_info,
|
||||
)
|
||||
return api
|
||||
|
||||
@classmethod
|
||||
async def create_from_netscape_cookies(
|
||||
cls,
|
||||
cookies_path: str = "./cookies.txt",
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "AppleMusicApi":
|
||||
cookies = MozillaCookieJar(cookies_path)
|
||||
cookies.load(ignore_discard=True, ignore_expires=True)
|
||||
parse_cookie = lambda name: next(
|
||||
(
|
||||
cookie.value
|
||||
for cookie in cookies
|
||||
if cookie.name == name and cookie.domain == APPLE_MUSIC_COOKIE_DOMAIN
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
media_user_token = parse_cookie("media-user-token")
|
||||
if not media_user_token:
|
||||
raise ValueError(
|
||||
'"media-user-token" cookie not found in cookies. '
|
||||
"Make sure you have exported the cookies from the Apple Music webpage "
|
||||
"and are logged in with an active subscription."
|
||||
)
|
||||
|
||||
return await cls.create(
|
||||
media_user_token=media_user_token,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def create_from_wrapper(
|
||||
cls,
|
||||
wrapper_account_url: str = "http://127.0.0.1:30020/",
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "AppleMusicApi":
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(wrapper_account_url)
|
||||
response.raise_for_status()
|
||||
wrapper_account_info = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching wrapper account info",
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
return await cls.create(
|
||||
media_user_token=wrapper_account_info["music_token"],
|
||||
token=wrapper_account_info["dev_token"],
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _amp_request(
|
||||
self,
|
||||
uri: str,
|
||||
params: dict | None = None,
|
||||
) -> dict:
|
||||
try:
|
||||
response = await self.client.get(
|
||||
APPLE_MUSIC_AMP_API_URL + uri,
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching from AMP API",
|
||||
content=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
if "errors" in response_json:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching from AMP API",
|
||||
content=response_json["errors"],
|
||||
)
|
||||
|
||||
return response_json
|
||||
|
||||
async def get_song(
|
||||
self,
|
||||
song_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
include: str = "lyrics,albums",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_song", song_id=song_id)
|
||||
|
||||
song = await self._amp_request(
|
||||
APPLE_MUSIC_SONG_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
song_id=song_id,
|
||||
),
|
||||
{
|
||||
"extend": extend,
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", song=song)
|
||||
|
||||
return song
|
||||
|
||||
async def get_music_video(
|
||||
self,
|
||||
music_video_id: str,
|
||||
include: str = "albums",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_music_video", music_video_id=music_video_id)
|
||||
|
||||
music_video = await self._amp_request(
|
||||
APPLE_MUSIC_MUSIC_VIDEO_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
music_video_id=music_video_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", music_video=music_video)
|
||||
|
||||
return music_video
|
||||
|
||||
async def get_uploaded_video(
|
||||
self,
|
||||
uploaded_video_id: str,
|
||||
) -> dict:
|
||||
log = logger.bind(
|
||||
action="get_uploaded_video", uploaded_video_id=uploaded_video_id
|
||||
)
|
||||
|
||||
uploaded_video = await self._amp_request(
|
||||
APPLE_MUSIC_UPLOADED_VIDEO_API_URL.format(
|
||||
storefront=self.storefront,
|
||||
uploaded_video_id=uploaded_video_id,
|
||||
)
|
||||
)
|
||||
|
||||
log.debug("success", uploaded_video=uploaded_video)
|
||||
|
||||
return uploaded_video
|
||||
|
||||
async def get_album(
|
||||
self,
|
||||
album_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_album", album_id=album_id)
|
||||
|
||||
album = await self._amp_request(
|
||||
APPLE_MUSIC_ALBUM_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
album_id=album_id,
|
||||
),
|
||||
{
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", album=album)
|
||||
|
||||
return album
|
||||
|
||||
async def get_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
limit_tracks: int = 300,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_playlist", playlist_id=playlist_id)
|
||||
|
||||
playlist = await self._amp_request(
|
||||
APPLE_MUSIC_PLAYLIST_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
playlist_id=playlist_id,
|
||||
),
|
||||
{
|
||||
"limit[tracks]": limit_tracks,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", playlist=playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def get_artist(
|
||||
self,
|
||||
artist_id: str,
|
||||
include: str = "albums,music-videos",
|
||||
views: str = "full-albums,compilation-albums,live-albums,singles,top-songs",
|
||||
limit: int = 100,
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_artist", artist_id=artist_id)
|
||||
|
||||
artist = await self._amp_request(
|
||||
APPLE_MUSIC_ARTIST_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
artist_id=artist_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
"views": views,
|
||||
**{
|
||||
f"limit[{_include}]": limit
|
||||
for _include in [*include.split(","), *views.split(",")]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", artist=artist)
|
||||
|
||||
return artist
|
||||
|
||||
async def get_library_album(
|
||||
self,
|
||||
album_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_album", album_id=album_id)
|
||||
|
||||
album = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_ALBUM_API_URI.format(
|
||||
album_id=album_id,
|
||||
),
|
||||
{
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", album=album)
|
||||
|
||||
return album
|
||||
|
||||
async def get_library_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
include: str = "tracks",
|
||||
limit: int = 100,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_playlist", playlist_id=playlist_id)
|
||||
|
||||
playlist = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI.format(
|
||||
playlist_id=playlist_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
**{f"limit[{_include}]": limit for _include in include.split(",")},
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", playlist=playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def get_search_results(
|
||||
self,
|
||||
term: str,
|
||||
types: str = "songs,music-videos,albums,playlists,artists",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_search_results", term=term, types=types)
|
||||
|
||||
search_results = await self._amp_request(
|
||||
APPLE_MUSIC_SEARCH_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
),
|
||||
{
|
||||
"term": term,
|
||||
"types": types,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", search_results=search_results)
|
||||
|
||||
return search_results
|
||||
|
||||
async def get_extended_api_data(
|
||||
self,
|
||||
next_uri: str | None,
|
||||
href_uri: str,
|
||||
) -> dict:
|
||||
log = logger.bind(
|
||||
action="extend_api_data", next_uri=next_uri, href_uri=href_uri
|
||||
)
|
||||
|
||||
if not next_uri:
|
||||
log.debug("no_next_uri")
|
||||
return
|
||||
|
||||
href_params = parse_qs(urlparse(href_uri).query)
|
||||
next_params = parse_qs(urlparse(next_uri).query)
|
||||
|
||||
if href_params.get("limit"):
|
||||
limit = int(href_params["limit"][0])
|
||||
else:
|
||||
limit = None
|
||||
|
||||
offset = int(next_params["offset"][0])
|
||||
|
||||
extended_data = await self._amp_request(
|
||||
urlparse(next_uri).path,
|
||||
{
|
||||
"offset": offset,
|
||||
**({"limit": limit} if limit else {}),
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", extended_data=extended_data)
|
||||
|
||||
return extended_data
|
||||
|
||||
async def get_webplayback(
|
||||
self,
|
||||
track_id: str,
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_webplayback", track_id=track_id)
|
||||
|
||||
try:
|
||||
response = await self.client.post(
|
||||
APPLE_MUSIC_WEBPLAYBACK_API_URL,
|
||||
json={
|
||||
"salableAdamId": track_id,
|
||||
"language": self.language,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
webplayback = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching webplayback data",
|
||||
content=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
if "dialog" in webplayback:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching webplayback data",
|
||||
content=webplayback["dialog"],
|
||||
)
|
||||
|
||||
log.debug("success", webplayback=webplayback)
|
||||
|
||||
return webplayback
|
||||
|
||||
async def get_license_exchange(
|
||||
self,
|
||||
track_id: str,
|
||||
track_uri: str,
|
||||
challenge: str,
|
||||
key_system: str = "com.widevine.alpha",
|
||||
is_library: bool = False,
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_license_exchange", track_id=track_id)
|
||||
|
||||
try:
|
||||
response = await self.client.post(
|
||||
APPLE_MUSIC_LICENSE_API_URL,
|
||||
json={
|
||||
"challenge": challenge,
|
||||
"key-system": key_system,
|
||||
"uri": track_uri,
|
||||
"adamId": track_id,
|
||||
"isLibrary": is_library,
|
||||
"user-initiated": True,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
license_exchange = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching license exchange data",
|
||||
content=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
if license_exchange.get("status") != 0:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching license exchange data",
|
||||
content=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
log.debug("success", license_exchange=license_exchange)
|
||||
|
||||
return license_exchange
|
||||
@@ -1,448 +0,0 @@
|
||||
import logging
|
||||
import re
|
||||
import typing
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ..utils import raise_for_status, safe_json
|
||||
from .constants import (
|
||||
AMP_API_URL,
|
||||
APPLE_MUSIC_COOKIE_DOMAIN,
|
||||
APPLE_MUSIC_HOMEPAGE_URL,
|
||||
LICENSE_API_URL,
|
||||
WEBPLAYBACK_API_URL,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleMusicApi:
|
||||
def __init__(
|
||||
self,
|
||||
storefront: str = "us",
|
||||
media_user_token: str | None = None,
|
||||
language: str = "en-US",
|
||||
) -> None:
|
||||
self.storefront = storefront
|
||||
self.media_user_token = media_user_token
|
||||
self.language = language
|
||||
|
||||
@classmethod
|
||||
def from_netscape_cookies(
|
||||
cls,
|
||||
cookies_path: str = "./cookies.txt",
|
||||
language: str = "en-US",
|
||||
) -> "AppleMusicApi":
|
||||
cookies = MozillaCookieJar(cookies_path)
|
||||
cookies.load(ignore_discard=True, ignore_expires=True)
|
||||
parse_cookie = lambda name: next(
|
||||
(
|
||||
cookie.value
|
||||
for cookie in cookies
|
||||
if cookie.name == name and cookie.domain == APPLE_MUSIC_COOKIE_DOMAIN
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
media_user_token = parse_cookie("media-user-token")
|
||||
if not media_user_token:
|
||||
raise ValueError(
|
||||
'"media-user-token" cookie not found in cookies. '
|
||||
"Make sure you have exported the cookies from the Apple Music webpage "
|
||||
"and are logged in with an active subscription."
|
||||
)
|
||||
|
||||
return cls(
|
||||
storefront=None,
|
||||
media_user_token=media_user_token,
|
||||
language=language,
|
||||
)
|
||||
|
||||
async def setup(self) -> None:
|
||||
await self._setup_client()
|
||||
await self._setup_token()
|
||||
await self._setup_account_info()
|
||||
|
||||
async def _setup_client(self) -> None:
|
||||
self.client = httpx.AsyncClient(
|
||||
headers={
|
||||
"accept": "*/*",
|
||||
"accept-language": "en-US",
|
||||
"origin": APPLE_MUSIC_HOMEPAGE_URL,
|
||||
"priority": "u=1, i",
|
||||
"referer": APPLE_MUSIC_HOMEPAGE_URL,
|
||||
"sec-ch-ua": '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-site",
|
||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
||||
},
|
||||
params={
|
||||
"l": self.language,
|
||||
},
|
||||
follow_redirects=True,
|
||||
transport=httpx.AsyncHTTPTransport(retries=3),
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
async def _setup_token(self) -> None:
|
||||
response = await self.client.get(APPLE_MUSIC_HOMEPAGE_URL)
|
||||
raise_for_status(response)
|
||||
home_page = response.text
|
||||
|
||||
index_js_uri_match = re.search(
|
||||
r"/(assets/index-legacy[~-][^/\"]+\.js)",
|
||||
home_page,
|
||||
)
|
||||
if not index_js_uri_match:
|
||||
raise Exception("index.js URI not found in Apple Music homepage")
|
||||
index_js_uri = index_js_uri_match.group(1)
|
||||
|
||||
response = await self.client.get(f"{APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}")
|
||||
raise_for_status(response)
|
||||
index_js_page = response.text
|
||||
|
||||
token_match = re.search('(?=eyJh)(.*?)(?=")', index_js_page)
|
||||
if not token_match:
|
||||
raise Exception("Token not found in index.js page")
|
||||
token = token_match.group(1)
|
||||
|
||||
logger.debug(f"Token: {token}")
|
||||
self.client.headers.update({"authorization": f"Bearer {token}"})
|
||||
|
||||
async def _setup_account_info(self) -> None:
|
||||
if not self.media_user_token:
|
||||
return
|
||||
|
||||
self.client.cookies.update(
|
||||
{
|
||||
"media-user-token": self.media_user_token,
|
||||
}
|
||||
)
|
||||
|
||||
self.account_info = await self.get_account_info()
|
||||
self.storefront = self.account_info["meta"]["subscription"]["storefront"]
|
||||
|
||||
async def get_account_info(self, meta: str | None = "subscription") -> dict:
|
||||
response = await self.client.get(
|
||||
f"{AMP_API_URL}/v1/me/account",
|
||||
params={
|
||||
**({"meta": meta} if meta else {}),
|
||||
},
|
||||
)
|
||||
raise_for_status(response)
|
||||
|
||||
account_info = safe_json(response)
|
||||
if not "data" in account_info or (meta and "meta" not in account_info):
|
||||
raise Exception("Error getting account info:", response.text)
|
||||
logger.debug(f"Account info: {account_info}")
|
||||
|
||||
return account_info
|
||||
|
||||
async def get_song(
|
||||
self,
|
||||
song_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
include: str = "lyrics,albums",
|
||||
) -> dict | None:
|
||||
response = await self.client.get(
|
||||
f"{AMP_API_URL}/v1/catalog/{self.storefront}/songs/{song_id}",
|
||||
params={
|
||||
"extend": extend,
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
raise_for_status(response, {200, 404})
|
||||
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
|
||||
song = safe_json(response)
|
||||
if not "data" in song:
|
||||
raise Exception("Error getting song:", response.text)
|
||||
logger.debug(f"Song: {song}")
|
||||
|
||||
return song
|
||||
|
||||
async def get_music_video(
|
||||
self,
|
||||
music_video_id: str,
|
||||
include: str = "albums",
|
||||
) -> dict | None:
|
||||
response = await self.client.get(
|
||||
f"{AMP_API_URL}/v1/catalog/{self.storefront}/music-videos/{music_video_id}",
|
||||
params={
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
raise_for_status(response, {200, 404})
|
||||
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
|
||||
music_video = safe_json(response)
|
||||
if not "data" in music_video:
|
||||
raise Exception("Error getting music video:", response.text)
|
||||
logger.debug(f"Music video: {music_video}")
|
||||
|
||||
return music_video
|
||||
|
||||
async def get_uploaded_video(
|
||||
self,
|
||||
post_id: str,
|
||||
) -> dict | None:
|
||||
response = await self.client.get(
|
||||
f"{AMP_API_URL}/v1/catalog/{self.storefront}/uploaded-videos/{post_id}"
|
||||
)
|
||||
raise_for_status(response, {200, 404})
|
||||
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
|
||||
uploaded_video = safe_json(response)
|
||||
if not "data" in uploaded_video:
|
||||
raise Exception("Error getting uploaded video:", response.text)
|
||||
logger.debug(f"Uploaded video: {uploaded_video}")
|
||||
|
||||
return uploaded_video
|
||||
|
||||
async def get_album(
|
||||
self,
|
||||
album_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict | None:
|
||||
response = await self.client.get(
|
||||
f"{AMP_API_URL}/v1/catalog/{self.storefront}/albums/{album_id}",
|
||||
params={
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
raise_for_status(response, {200, 404})
|
||||
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
|
||||
album = safe_json(response)
|
||||
if not "data" in album:
|
||||
raise Exception("Error getting album:", response.text)
|
||||
logger.debug(f"Album: {album}")
|
||||
|
||||
return album
|
||||
|
||||
async def get_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
limit_tracks: int = 300,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict | None:
|
||||
response = await self.client.get(
|
||||
f"{AMP_API_URL}/v1/catalog/{self.storefront}/playlists/{playlist_id}",
|
||||
params={
|
||||
"limit[tracks]": limit_tracks,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
raise_for_status(response, {200, 404})
|
||||
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
|
||||
playlist = safe_json(response)
|
||||
if not "data" in playlist:
|
||||
raise Exception("Error getting playlist:", response.text)
|
||||
logger.debug(f"Playlist: {playlist}")
|
||||
|
||||
return playlist
|
||||
|
||||
async def get_artist(
|
||||
self,
|
||||
artist_id: str,
|
||||
include: str = "albums,music-videos",
|
||||
limit: int = 100,
|
||||
) -> dict | None:
|
||||
response = await self.client.get(
|
||||
f"{AMP_API_URL}/v1/catalog/{self.storefront}/artists/{artist_id}",
|
||||
params={
|
||||
"include": include,
|
||||
**{f"limit[{_include}]": limit for _include in include.split(",")},
|
||||
},
|
||||
)
|
||||
raise_for_status(response, {200, 404})
|
||||
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
|
||||
artist = safe_json(response)
|
||||
if not "data" in artist:
|
||||
raise Exception("Error getting artist:", response.text)
|
||||
logger.debug(f"Artist: {artist}")
|
||||
|
||||
return artist
|
||||
|
||||
async def get_library_album(
|
||||
self,
|
||||
album_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict | None:
|
||||
response = await self.client.get(
|
||||
f"{AMP_API_URL}/v1/me/library/albums/{album_id}",
|
||||
params={
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
raise_for_status(response, {200, 404})
|
||||
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
|
||||
album = safe_json(response)
|
||||
if not "data" in album:
|
||||
raise Exception("Error getting library album:", response.text)
|
||||
logger.debug(f"Library album: {album}")
|
||||
|
||||
return album
|
||||
|
||||
async def get_library_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
include: str = "tracks",
|
||||
limit: int = 100,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict | None:
|
||||
response = await self.client.get(
|
||||
f"{AMP_API_URL}/v1/me/library/playlists/{playlist_id}",
|
||||
params={
|
||||
"include": include,
|
||||
**{f"limit[{_include}]": limit for _include in include.split(",")},
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
raise_for_status(response, {200, 404})
|
||||
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
|
||||
playlist = safe_json(response)
|
||||
if not "data" in playlist:
|
||||
raise Exception("Error getting library playlist:", response.text)
|
||||
|
||||
return playlist
|
||||
|
||||
async def get_search_results(
|
||||
self,
|
||||
term: str,
|
||||
types: str = "songs,music-videos,albums,playlists,artists",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
response = await self.client.get(
|
||||
f"{AMP_API_URL}/v1/catalog/{self.storefront}/search",
|
||||
params={
|
||||
"term": term,
|
||||
"types": types,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
)
|
||||
raise_for_status(response)
|
||||
|
||||
search_results = safe_json(response)
|
||||
if not "results" in search_results:
|
||||
raise Exception("Error searching:", response.text)
|
||||
logger.debug(f"Search results: {search_results}")
|
||||
|
||||
return search_results
|
||||
|
||||
async def extend_api_data(
|
||||
self,
|
||||
api_response: dict,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> typing.AsyncGenerator[dict, None]:
|
||||
next_uri = api_response.get("next")
|
||||
if not next_uri:
|
||||
return
|
||||
|
||||
next_uri_params = parse_qs(urlparse(next_uri).query)
|
||||
limit = int(next_uri_params["offset"][0])
|
||||
while next_uri:
|
||||
extended_api_data = await self._get_extended_api_data(
|
||||
next_uri,
|
||||
limit,
|
||||
extend,
|
||||
)
|
||||
yield extended_api_data
|
||||
next_uri = extended_api_data.get("next")
|
||||
|
||||
async def _get_extended_api_data(
|
||||
self,
|
||||
next_uri: str,
|
||||
limit: int,
|
||||
extend: str,
|
||||
) -> dict:
|
||||
response = await self.client.get(
|
||||
AMP_API_URL + next_uri,
|
||||
params={
|
||||
"limit": limit,
|
||||
"extend": extend,
|
||||
**parse_qs(urlparse(next_uri).query),
|
||||
},
|
||||
)
|
||||
raise_for_status(response)
|
||||
|
||||
extended_api_data = safe_json(response)
|
||||
if not "data" in extended_api_data:
|
||||
raise Exception("Error getting extended API data:", response.text)
|
||||
logger.debug(f"Extended API data: {extended_api_data}")
|
||||
|
||||
return extended_api_data
|
||||
|
||||
async def get_webplayback(
|
||||
self,
|
||||
track_id: str,
|
||||
) -> dict:
|
||||
response = await self.client.post(
|
||||
WEBPLAYBACK_API_URL,
|
||||
json={
|
||||
"salableAdamId": track_id,
|
||||
"language": self.language,
|
||||
},
|
||||
)
|
||||
raise_for_status(response)
|
||||
|
||||
webplayback = safe_json(response)
|
||||
if not "songList" in webplayback:
|
||||
raise Exception("Error getting webplayback:", response.text)
|
||||
logger.debug(f"Webplayback: {webplayback}")
|
||||
|
||||
return webplayback
|
||||
|
||||
async def get_license_exchange(
|
||||
self,
|
||||
track_id: str,
|
||||
track_uri: str,
|
||||
challenge: str,
|
||||
key_system: str = "com.widevine.alpha",
|
||||
) -> dict:
|
||||
response = await self.client.post(
|
||||
LICENSE_API_URL,
|
||||
json={
|
||||
"challenge": challenge,
|
||||
"key-system": key_system,
|
||||
"uri": track_uri,
|
||||
"adamId": track_id,
|
||||
"isLibrary": False,
|
||||
"user-initiated": True,
|
||||
},
|
||||
)
|
||||
raise_for_status(response)
|
||||
|
||||
license_exchange = safe_json(response)
|
||||
if not "license" in license_exchange:
|
||||
raise Exception("Error getting license exchange:", response.text)
|
||||
logger.debug(f"License exchange: {license_exchange}")
|
||||
|
||||
return license_exchange
|
||||
+26
-161
@@ -1,169 +1,34 @@
|
||||
APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com"
|
||||
|
||||
APPLE_MUSIC_COOKIE_DOMAIN = ".music.apple.com"
|
||||
AMP_API_URL = "https://amp-api.music.apple.com"
|
||||
WEBPLAYBACK_API_URL = (
|
||||
|
||||
APPLE_MUSIC_AMP_API_URL = "https://amp-api.music.apple.com"
|
||||
APPLE_MUSIC_ACCOUNT_INFO_API_URI = "/v1/me/account"
|
||||
APPLE_MUSIC_SONG_API_URI = "/v1/catalog/{storefront}/songs/{song_id}"
|
||||
APPLE_MUSIC_MUSIC_VIDEO_API_URI = (
|
||||
"/v1/catalog/{storefront}/music-videos/{music_video_id}"
|
||||
)
|
||||
APPLE_MUSIC_UPLOADED_VIDEO_API_URL = (
|
||||
"/v1/catalog/{storefront}/uploaded-videos/{uploaded_video_id}"
|
||||
)
|
||||
APPLE_MUSIC_ALBUM_API_URI = "/v1/catalog/{storefront}/albums/{album_id}"
|
||||
APPLE_MUSIC_PLAYLIST_API_URI = "/v1/catalog/{storefront}/playlists/{playlist_id}"
|
||||
APPLE_MUSIC_ARTIST_API_URI = "/v1/catalog/{storefront}/artists/{artist_id}"
|
||||
APPLE_MUSIC_LIBRARY_ALBUM_API_URI = "/v1/me/library/albums/{album_id}"
|
||||
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI = "/v1/me/library/playlists/{playlist_id}"
|
||||
APPLE_MUSIC_SEARCH_API_URI = "/v1/catalog/{storefront}/search"
|
||||
|
||||
APPLE_MUSIC_WEBPLAYBACK_API_URL = (
|
||||
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
|
||||
)
|
||||
LICENSE_API_URL = (
|
||||
|
||||
APPLE_MUSIC_LICENSE_API_URL = (
|
||||
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense"
|
||||
)
|
||||
|
||||
APPLE_MUSIC_MUSIC_KIT_URL = (
|
||||
"https://music.apple.com/includes/js-cdn/musickit/v3/amp/musickit.js"
|
||||
)
|
||||
|
||||
ITUNES_LOOKUP_API_URL = "https://itunes.apple.com/lookup"
|
||||
ITUNES_PAGE_API_URL = "https://music.apple.com"
|
||||
STOREFRONT_IDS = {
|
||||
"AE": "143481-2,32",
|
||||
"AG": "143540-2,32",
|
||||
"AI": "143538-2,32",
|
||||
"AL": "143575-2,32",
|
||||
"AM": "143524-2,32",
|
||||
"AO": "143564-2,32",
|
||||
"AR": "143505-28,32",
|
||||
"AT": "143445-4,32",
|
||||
"AU": "143460-27,32",
|
||||
"AZ": "143568-2,32",
|
||||
"BB": "143541-2,32",
|
||||
"BE": "143446-2,32",
|
||||
"BF": "143578-2,32",
|
||||
"BG": "143526-2,32",
|
||||
"BH": "143559-2,32",
|
||||
"BJ": "143576-2,32",
|
||||
"BM": "143542-2,32",
|
||||
"BN": "143560-2,32",
|
||||
"BO": "143556-28,32",
|
||||
"BR": "143503-15,32",
|
||||
"BS": "143539-2,32",
|
||||
"BT": "143577-2,32",
|
||||
"BW": "143525-2,32",
|
||||
"BY": "143565-2,32",
|
||||
"BZ": "143555-2,32",
|
||||
"CA": "143455-6,32",
|
||||
"CG": "143582-2,32",
|
||||
"CH": "143459-57,32",
|
||||
"CL": "143483-28,32",
|
||||
"CN": "143465-19,32",
|
||||
"CO": "143501-28,32",
|
||||
"CR": "143495-28,32",
|
||||
"CV": "143580-2,32",
|
||||
"CY": "143557-2,32",
|
||||
"CZ": "143489-2,32",
|
||||
"DE": "143443-4,32",
|
||||
"DK": "143458-2,32",
|
||||
"DM": "143545-2,32",
|
||||
"DO": "143508-28,32",
|
||||
"DZ": "143563-2,32",
|
||||
"EC": "143509-28,32",
|
||||
"EE": "143518-2,32",
|
||||
"EG": "143516-2,32",
|
||||
"ES": "143454-8,32",
|
||||
"FI": "143447-2,32",
|
||||
"FJ": "143583-2,32",
|
||||
"FM": "143591-2,32",
|
||||
"FR": "143442-3,32",
|
||||
"GB": "143444-2,32",
|
||||
"GD": "143546-2,32",
|
||||
"GH": "143573-2,32",
|
||||
"GM": "143584-2,32",
|
||||
"GR": "143448-2,32",
|
||||
"GT": "143504-28,32",
|
||||
"GW": "143585-2,32",
|
||||
"GY": "143553-2,32",
|
||||
"HK": "143463-45,32",
|
||||
"HN": "143510-28,32",
|
||||
"HR": "143494-2,32",
|
||||
"HU": "143482-2,32",
|
||||
"ID": "143476-2,32",
|
||||
"IE": "143449-2,32",
|
||||
"IL": "143491-2,32",
|
||||
"IN": "143467-2,32",
|
||||
"IS": "143558-2,32",
|
||||
"IT": "143450-7,32",
|
||||
"JM": "143511-2,32",
|
||||
"JO": "143528-2,32",
|
||||
"JP": "143462-9,32",
|
||||
"KE": "143529-2,32",
|
||||
"KG": "143586-2,32",
|
||||
"KH": "143579-2,32",
|
||||
"KN": "143548-2,32",
|
||||
"KR": "143466-13,32",
|
||||
"KW": "143493-2,32",
|
||||
"KY": "143544-2,32",
|
||||
"KZ": "143517-2,32",
|
||||
"LA": "143587-2,32",
|
||||
"LB": "143497-2,32",
|
||||
"LC": "143549-2,32",
|
||||
"LK": "143486-2,32",
|
||||
"LR": "143588-2,32",
|
||||
"LT": "143520-2,32",
|
||||
"LU": "143451-2,32",
|
||||
"LV": "143519-2,32",
|
||||
"MD": "143523-2,32",
|
||||
"MG": "143531-2,32",
|
||||
"MK": "143530-2,32",
|
||||
"ML": "143532-2,32",
|
||||
"MN": "143592-2,32",
|
||||
"MO": "143515-45,32",
|
||||
"MR": "143590-2,32",
|
||||
"MS": "143547-2,32",
|
||||
"MT": "143521-2,32",
|
||||
"MU": "143533-2,32",
|
||||
"MW": "143589-2,32",
|
||||
"MX": "143468-28,32",
|
||||
"MY": "143473-2,32",
|
||||
"MZ": "143593-2,32",
|
||||
"NA": "143594-2,32",
|
||||
"NE": "143534-2,32",
|
||||
"NG": "143561-2,32",
|
||||
"NI": "143512-28,32",
|
||||
"NL": "143452-10,32",
|
||||
"NO": "143457-2,32",
|
||||
"NP": "143484-2,32",
|
||||
"NZ": "143461-27,32",
|
||||
"OM": "143562-2,32",
|
||||
"PA": "143485-28,32",
|
||||
"PE": "143507-28,32",
|
||||
"PG": "143597-2,32",
|
||||
"PH": "143474-2,32",
|
||||
"PK": "143477-2,32",
|
||||
"PL": "143478-2,32",
|
||||
"PT": "143453-24,32",
|
||||
"PW": "143595-2,32",
|
||||
"PY": "143513-28,32",
|
||||
"QA": "143498-2,32",
|
||||
"RO": "143487-2,32",
|
||||
"RU": "143469-16,32",
|
||||
"SA": "143479-2,32",
|
||||
"SB": "143601-2,32",
|
||||
"SC": "143599-2,32",
|
||||
"SE": "143456-17,32",
|
||||
"SG": "143464-19,32",
|
||||
"SI": "143499-2,32",
|
||||
"SK": "143496-2,32",
|
||||
"SL": "143600-2,32",
|
||||
"SN": "143535-2,32",
|
||||
"SR": "143554-2,32",
|
||||
"ST": "143598-2,32",
|
||||
"SV": "143506-28,32",
|
||||
"SZ": "143602-2,32",
|
||||
"TC": "143552-2,32",
|
||||
"TD": "143581-2,32",
|
||||
"TH": "143475-2,32",
|
||||
"TJ": "143603-2,32",
|
||||
"TM": "143604-2,32",
|
||||
"TN": "143536-2,32",
|
||||
"TR": "143480-2,32",
|
||||
"TT": "143551-2,32",
|
||||
"TW": "143470-18,32",
|
||||
"TZ": "143572-2,32",
|
||||
"UA": "143492-2,32",
|
||||
"UG": "143537-2,32",
|
||||
"US": "143441-1,32",
|
||||
"UY": "143514-2,32",
|
||||
"UZ": "143566-2,32",
|
||||
"VC": "143550-2,32",
|
||||
"VE": "143502-28,32",
|
||||
"VG": "143543-2,32",
|
||||
"VN": "143471-2,32",
|
||||
"YE": "143571-2,32",
|
||||
"ZA": "143472-2,32",
|
||||
"ZW": "143605-2,32",
|
||||
}
|
||||
ITUNES_PAGE_API_URL = "https://music.apple.com/{media_type}/{media_id}"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from ..utils import GamdlError
|
||||
|
||||
|
||||
class GamdlApiError(GamdlError):
|
||||
pass
|
||||
|
||||
|
||||
class GamdlApiResponseError(GamdlApiError):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
content: str,
|
||||
status_code: int | None = None,
|
||||
):
|
||||
self.message = message
|
||||
self.content = content
|
||||
self.status_code = status_code
|
||||
|
||||
if status_code is not None:
|
||||
message = f"{message} (Status code: {status_code})"
|
||||
|
||||
if content:
|
||||
message += f": {content}"
|
||||
|
||||
super().__init__(message)
|
||||
@@ -0,0 +1,147 @@
|
||||
import re
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from .constants import (
|
||||
APPLE_MUSIC_MUSIC_KIT_URL,
|
||||
ITUNES_LOOKUP_API_URL,
|
||||
ITUNES_PAGE_API_URL,
|
||||
)
|
||||
from .exceptions import GamdlApiResponseError
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ItunesApi:
|
||||
def __init__(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
storefront: str,
|
||||
language: str,
|
||||
storefront_id: int,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
self.storefront_id = storefront_id
|
||||
|
||||
@staticmethod
|
||||
async def get_storefront_id(storefront: str) -> int:
|
||||
log = logger.bind(action="get_storefront_id", storefront=storefront)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(APPLE_MUSIC_MUSIC_KIT_URL)
|
||||
response.raise_for_status()
|
||||
music_kit_content = response.text
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching MusicKit content",
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
normalized_storefront = storefront.upper()
|
||||
|
||||
country_code_pattern = f'{normalized_storefront}:"([A-Z]{{3}})"'
|
||||
country_code_match = re.search(country_code_pattern, music_kit_content)
|
||||
if not country_code_match:
|
||||
raise GamdlApiResponseError(
|
||||
f"Country code {storefront} not found in MusicKit content"
|
||||
)
|
||||
|
||||
three_letter_code = country_code_match.group(1)
|
||||
|
||||
storefront_pattern = f'{three_letter_code}:"(\\d+)"'
|
||||
storefront_match = re.search(storefront_pattern, music_kit_content)
|
||||
if not storefront_match:
|
||||
raise GamdlApiResponseError(
|
||||
f"Storefront ID not found for country code {storefront}"
|
||||
)
|
||||
|
||||
storefront_id = int(storefront_match.group(1))
|
||||
|
||||
log.debug("success", storefront_id=storefront_id)
|
||||
|
||||
return storefront_id
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
storefront: str = "us",
|
||||
storefront_id: int | None = 143441,
|
||||
language: str = "en-US",
|
||||
) -> "ItunesApi":
|
||||
storefront_id = storefront_id or await cls.get_storefront_id(storefront)
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
return cls(
|
||||
client=client,
|
||||
storefront=storefront,
|
||||
language=language,
|
||||
storefront_id=storefront_id,
|
||||
)
|
||||
|
||||
async def get_lookup_result(
|
||||
self,
|
||||
media_id: str,
|
||||
entity: str = "album",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_lookup_result", media_id=media_id, entity=entity)
|
||||
|
||||
try:
|
||||
response = await self.client.get(
|
||||
ITUNES_LOOKUP_API_URL,
|
||||
params={
|
||||
"id": media_id,
|
||||
"entity": entity,
|
||||
"country": self.storefront,
|
||||
"lang": self.language,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
lookup_result = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching iTunes lookup result",
|
||||
content=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
log.debug("success", lookup_result=lookup_result)
|
||||
|
||||
return lookup_result
|
||||
|
||||
async def get_itunes_page(
|
||||
self,
|
||||
media_type: str,
|
||||
media_id: str,
|
||||
) -> dict:
|
||||
log = logger.bind(
|
||||
action="get_itunes_page",
|
||||
media_type=media_type,
|
||||
media_id=media_id,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.client.get(
|
||||
ITUNES_PAGE_API_URL.format(media_type=media_type, media_id=media_id),
|
||||
headers={
|
||||
"X-Apple-Store-Front": f"{self.storefront_id}-1,32 t:music31",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
itunes_page = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching iTunes page",
|
||||
content=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
log.debug("success", itunes_page=itunes_page)
|
||||
|
||||
return itunes_page
|
||||
@@ -1,77 +0,0 @@
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from ..utils import raise_for_status, safe_json
|
||||
from .constants import ITUNES_LOOKUP_API_URL, ITUNES_PAGE_API_URL, STOREFRONT_IDS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ItunesApi:
|
||||
def __init__(
|
||||
self,
|
||||
storefront: str = "us",
|
||||
language: str = "en-US",
|
||||
) -> None:
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
|
||||
def setup(self) -> None:
|
||||
self._setup_storefront_id()
|
||||
self._setup_session()
|
||||
|
||||
def _setup_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:
|
||||
self.client = httpx.AsyncClient(
|
||||
params={
|
||||
"country": self.storefront,
|
||||
"lang": self.language,
|
||||
},
|
||||
headers={
|
||||
"X-Apple-Store-Front": f"{self.storefront_id} t:music31",
|
||||
},
|
||||
)
|
||||
|
||||
async def get_lookup_result(
|
||||
self,
|
||||
media_id: str,
|
||||
entity: str = "album",
|
||||
) -> dict:
|
||||
response = await self.client.get(
|
||||
ITUNES_LOOKUP_API_URL,
|
||||
params={
|
||||
"id": media_id,
|
||||
"entity": entity,
|
||||
},
|
||||
)
|
||||
raise_for_status(response)
|
||||
|
||||
lookup_result = safe_json(response)
|
||||
if "results" not in lookup_result:
|
||||
raise Exception("Error getting lookup result:", response.text)
|
||||
logger.debug(f"Lookup result: {lookup_result}")
|
||||
|
||||
return lookup_result
|
||||
|
||||
async def get_itunes_page(
|
||||
self,
|
||||
media_type: str,
|
||||
media_id: str,
|
||||
) -> dict:
|
||||
response = await self.client.get(
|
||||
f"{ITUNES_PAGE_API_URL}/{media_type}/{media_id}"
|
||||
)
|
||||
raise_for_status(response)
|
||||
|
||||
itunes_page = safe_json(response)
|
||||
if "storePlatformData" not in itunes_page:
|
||||
raise Exception("Error getting iTunes page:", response.text)
|
||||
logger.debug(f"iTunes page: {itunes_page}")
|
||||
|
||||
return itunes_page
|
||||
+213
-477
@@ -1,8 +1,14 @@
|
||||
import inspect
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import colorama
|
||||
import structlog
|
||||
from dataclass_click import dataclass_click
|
||||
from httpx import ConnectError
|
||||
|
||||
from .. import __version__
|
||||
from ..api import AppleMusicApi
|
||||
@@ -12,482 +18,214 @@ from ..downloader import (
|
||||
AppleMusicMusicVideoDownloader,
|
||||
AppleMusicSongDownloader,
|
||||
AppleMusicUploadedVideoDownloader,
|
||||
CoverFormat,
|
||||
DownloadItem,
|
||||
DownloadMode,
|
||||
MediaDownloadConfigurationError,
|
||||
MediaFormatNotAvailableError,
|
||||
MediaNotStreamableError,
|
||||
RemuxFormatMusicVideo,
|
||||
RemuxMode,
|
||||
GamdlDownloaderDependencyNotFoundError,
|
||||
GamdlDownloaderFlatFilterExcludedError,
|
||||
GamdlDownloaderMediaFileExistsError,
|
||||
GamdlDownloaderSyncedLyricsOnlyError,
|
||||
)
|
||||
from ..interface import (
|
||||
MusicVideoCodec,
|
||||
MusicVideoResolution,
|
||||
SongCodec,
|
||||
SyncedLyricsFormat,
|
||||
UploadedVideoQuality,
|
||||
AppleMusicBaseInterface,
|
||||
AppleMusicInterface,
|
||||
AppleMusicMusicVideoInterface,
|
||||
AppleMusicSongInterface,
|
||||
AppleMusicUploadedVideoInterface,
|
||||
GamdlInterfaceArtistMediaTypeError,
|
||||
GamdlInterfaceDecryptionNotAvailableError,
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
GamdlInterfaceUrlParseError,
|
||||
)
|
||||
from .constants import X_NOT_IN_PATH
|
||||
from .utils import Csv, CustomLoggerFormatter, PathPrompt, load_config_file, make_sync
|
||||
from .cli_config import CliConfig
|
||||
from .config_file import ConfigFile
|
||||
from .database import Database
|
||||
from .interactive_prompts import InteractivePrompts
|
||||
from .utils import custom_structlog_formatter, prompt_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__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)
|
||||
@ConfigFile.loader
|
||||
@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())
|
||||
stream_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
root_logger.addHandler(stream_handler)
|
||||
|
||||
if log_file:
|
||||
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||
file_handler.setFormatter(CustomLoggerFormatter(use_colors=False))
|
||||
if config.log_file:
|
||||
file_handler = logging.FileHandler(config.log_file, encoding="utf-8")
|
||||
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.processors.add_log_level,
|
||||
custom_structlog_formatter,
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
)
|
||||
|
||||
logger.info(f"Starting Gamdl {__version__}")
|
||||
|
||||
api = AppleMusicApi.from_netscape_cookies(
|
||||
cookies_path=cookies_path,
|
||||
language=language,
|
||||
)
|
||||
await api.setup()
|
||||
if config.use_wrapper:
|
||||
try:
|
||||
apple_music_api = await AppleMusicApi.create_from_wrapper(
|
||||
wrapper_account_url=config.wrapper_account_url,
|
||||
language=config.language,
|
||||
)
|
||||
except ConnectError:
|
||||
logger.critical(
|
||||
"Could not connect to the wrapper account API. "
|
||||
"Make sure the wrapper is running and the URL is correct."
|
||||
)
|
||||
return
|
||||
else:
|
||||
cookies_path = prompt_path(config.cookies_path)
|
||||
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
|
||||
cookies_path=cookies_path,
|
||||
language=config.language,
|
||||
)
|
||||
|
||||
if not api.account_info["meta"]["subscription"]["active"]:
|
||||
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"
|
||||
)
|
||||
|
||||
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,
|
||||
if (
|
||||
any(not codec.is_legacy() for codec in config.song_codec_piority)
|
||||
and not config.use_wrapper
|
||||
):
|
||||
logger.warning(
|
||||
"You have chosen an experimental song codec "
|
||||
"without enabling wrapper. "
|
||||
"They're not guaranteed to work due to API limitations."
|
||||
)
|
||||
|
||||
if config.database_path:
|
||||
database = Database(config.database_path)
|
||||
flat_filter = database.flat_filter
|
||||
else:
|
||||
database = None
|
||||
flat_filter = None
|
||||
|
||||
interactive_prompts = InteractivePrompts(
|
||||
artist_auto_select=config.artist_auto_select,
|
||||
)
|
||||
|
||||
base_interface = await AppleMusicBaseInterface.create(
|
||||
apple_music_api=apple_music_api,
|
||||
cover_format=config.cover_format,
|
||||
cover_size=config.cover_size,
|
||||
wvd_path=config.wvd_path,
|
||||
)
|
||||
|
||||
song_interface = AppleMusicSongInterface(
|
||||
base=base_interface,
|
||||
synced_lyrics_format=config.synced_lyrics_format,
|
||||
codec_priority=config.song_codec_piority,
|
||||
use_album_date=config.use_album_date,
|
||||
skip_decryption_key_non_legacy=config.use_wrapper,
|
||||
skip_stream_info=config.synced_lyrics_only,
|
||||
ask_codec_function=interactive_prompts.ask_song_codec,
|
||||
)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(
|
||||
base=base_interface,
|
||||
resolution=config.music_video_resolution,
|
||||
codec_priority=config.music_video_codec_priority,
|
||||
ask_video_codec_function=interactive_prompts.ask_music_video_video_codec_function,
|
||||
ask_audio_codec_function=interactive_prompts.ask_music_video_audio_codec_function,
|
||||
)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(
|
||||
base=base_interface,
|
||||
quality=config.uploaded_video_quality,
|
||||
ask_quality_function=interactive_prompts.ask_uploaded_video_quality_function,
|
||||
)
|
||||
|
||||
interface = AppleMusicInterface(
|
||||
song=song_interface,
|
||||
music_video=music_video_interface,
|
||||
uploaded_video=uploaded_video_interface,
|
||||
artist_select_media_type_function=interactive_prompts.ask_artist_media_type,
|
||||
artist_select_items_function=interactive_prompts.ask_artist_select_items,
|
||||
flat_filter_function=flat_filter,
|
||||
)
|
||||
|
||||
base_downloader = AppleMusicBaseDownloader(
|
||||
interface=interface,
|
||||
output_path=config.output_path,
|
||||
temp_path=config.temp_path,
|
||||
nm3u8dlre_path=config.nm3u8dlre_path,
|
||||
mp4decrypt_path=config.mp4decrypt_path,
|
||||
ffmpeg_path=config.ffmpeg_path,
|
||||
mp4box_path=config.mp4box_path,
|
||||
use_wrapper=config.use_wrapper,
|
||||
wrapper_decrypt_ip=config.wrapper_decrypt_ip,
|
||||
download_mode=config.download_mode,
|
||||
album_folder_template=config.album_folder_template,
|
||||
compilation_folder_template=config.compilation_folder_template,
|
||||
no_album_folder_template=config.no_album_folder_template,
|
||||
playlist_folder_template=config.playlist_folder_template,
|
||||
single_disc_file_template=config.single_disc_file_template,
|
||||
multi_disc_file_template=config.multi_disc_file_template,
|
||||
no_album_file_template=config.no_album_file_template,
|
||||
playlist_file_template=config.playlist_file_template,
|
||||
date_tag_template=config.date_tag_template,
|
||||
exclude_tags=config.exclude_tags,
|
||||
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=base_downloader,
|
||||
)
|
||||
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=base_downloader,
|
||||
remux_mode=config.music_video_remux_mode,
|
||||
remux_format=config.music_video_remux_format,
|
||||
)
|
||||
music_video_downloader.setup()
|
||||
|
||||
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
|
||||
base_downloader,
|
||||
quality=uploaded_video_quality,
|
||||
base=base_downloader,
|
||||
)
|
||||
uploaded_video_downloader.setup()
|
||||
|
||||
downloader = AppleMusicDownloader(
|
||||
base_downloader,
|
||||
song_downloader,
|
||||
music_video_downloader,
|
||||
uploaded_video_downloader,
|
||||
song=song_downloader,
|
||||
music_video=music_video_downloader,
|
||||
uploaded_video=uploaded_video_downloader,
|
||||
overwrite=config.overwrite,
|
||||
save_cover=config.save_cover,
|
||||
save_playlist=config.save_playlist,
|
||||
no_synced_lyrics=config.no_synced_lyrics,
|
||||
synced_lyrics_only=config.synced_lyrics_only,
|
||||
)
|
||||
|
||||
if not synced_lyrics_only:
|
||||
if not base_downloader.full_ffmpeg_path and (
|
||||
remux_mode == RemuxMode.FFMPEG or 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))
|
||||
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
|
||||
)
|
||||
):
|
||||
logger.critical(X_NOT_IN_PATH.format("mp4decrypt", mp4decrypt_path))
|
||||
return
|
||||
|
||||
if (
|
||||
download_mode == DownloadMode.NM3U8DLRE
|
||||
and not base_downloader.full_nm3u8dlre_path
|
||||
):
|
||||
logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", nm3u8dlre_path))
|
||||
return
|
||||
|
||||
if not base_downloader.full_mp4decrypt_path:
|
||||
logger.warning(
|
||||
X_NOT_IN_PATH.format("mp4decrypt", mp4decrypt_path)
|
||||
+ ", music videos will not be downloaded"
|
||||
)
|
||||
downloader.skip_music_videos = True
|
||||
|
||||
if not codec_song.is_legacy():
|
||||
logger.warning(
|
||||
"You have chosen an experimental song codec. "
|
||||
"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,76 +235,74 @@ async def main(
|
||||
]
|
||||
)
|
||||
urls = urls_from_file
|
||||
else:
|
||||
urls = config.urls
|
||||
|
||||
error_count = 0
|
||||
for url_index, url in enumerate(urls, 1):
|
||||
url_progress = click.style(f"[URL {url_index}/{len(urls)}]", dim=True)
|
||||
logger.info(url_progress + f' Processing "{url}"')
|
||||
download_queue = None
|
||||
url_log = logger.bind(action=f"URL {url_index:>3}/{len(urls):<3}")
|
||||
|
||||
url_log.info(f'Processing "{url}"')
|
||||
|
||||
try:
|
||||
url_info = downloader.get_url_info(url)
|
||||
if not url_info:
|
||||
logger.warning(
|
||||
url_progress + f' Could not parse "{url}", skipping.',
|
||||
)
|
||||
continue
|
||||
|
||||
download_queue = await downloader.get_download_queue(url_info)
|
||||
if not download_queue:
|
||||
logger.warning(
|
||||
url_progress
|
||||
+ f' No downloadable media found for "{url}", skipping.',
|
||||
)
|
||||
continue
|
||||
except KeyboardInterrupt:
|
||||
exit(1)
|
||||
download_queue: list[DownloadItem] = []
|
||||
async for media in downloader.get_download_item_from_url(url):
|
||||
download_queue.append(media)
|
||||
except GamdlInterfaceUrlParseError as e:
|
||||
url_log.warning(f"{e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
url_log.error(f'Error processing "{url}": {e}')
|
||||
error_count += 1
|
||||
logger.error(
|
||||
url_progress + f' Error processing "{url}"',
|
||||
exc_info=not no_exceptions,
|
||||
)
|
||||
|
||||
if not download_queue:
|
||||
if not config.no_exceptions:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
|
||||
for download_index, download_item in enumerate(
|
||||
download_queue,
|
||||
1,
|
||||
):
|
||||
download_queue_progress = click.style(
|
||||
f"[Track {download_index}/{len(download_queue)}]",
|
||||
dim=True,
|
||||
track_log = logger.bind(
|
||||
action=f"Track {download_index:>3}/{len(download_queue):<3}"
|
||||
)
|
||||
|
||||
media_title = (
|
||||
download_item.media_metadata["attributes"]["name"]
|
||||
if isinstance(
|
||||
download_item,
|
||||
DownloadItem,
|
||||
)
|
||||
download_item.media.media_metadata["attributes"]["name"]
|
||||
if download_item.media.media_metadata
|
||||
and download_item.media.media_metadata.get("attributes", {}).get("name")
|
||||
else "Unknown Title"
|
||||
)
|
||||
logger.info(download_queue_progress + f' Downloading "{media_title}"')
|
||||
|
||||
track_log.info(f'Downloading "{media_title}"')
|
||||
|
||||
try:
|
||||
await downloader.download(download_item)
|
||||
except (
|
||||
FileExistsError,
|
||||
MediaNotStreamableError,
|
||||
MediaFormatNotAvailableError,
|
||||
MediaDownloadConfigurationError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceDecryptionNotAvailableError,
|
||||
GamdlInterfaceArtistMediaTypeError,
|
||||
GamdlDownloaderSyncedLyricsOnlyError,
|
||||
GamdlDownloaderMediaFileExistsError,
|
||||
GamdlDownloaderDependencyNotFoundError,
|
||||
GamdlDownloaderFlatFilterExcludedError,
|
||||
) as e:
|
||||
logger.warning(
|
||||
download_queue_progress + f' Skipping "{media_title}": {e}'
|
||||
)
|
||||
track_log.warning(f'Skipping "{media_title}": {e}')
|
||||
continue
|
||||
except KeyboardInterrupt:
|
||||
exit(1)
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
download_queue_progress + f' Error downloading "{media_title}"',
|
||||
exc_info=not no_exceptions,
|
||||
track_log.error(f'Error downloading "{media_title}"')
|
||||
if not config.no_exceptions:
|
||||
traceback.print_exc()
|
||||
|
||||
if (
|
||||
database
|
||||
and download_item.media.media_metadata
|
||||
and download_item.final_path
|
||||
):
|
||||
database.add(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.final_path,
|
||||
)
|
||||
|
||||
logger.info(f"Finished with {error_count} error(s)")
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
import inspect
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import click
|
||||
from dataclass_click import argument, option
|
||||
|
||||
from ..api import AppleMusicApi
|
||||
from ..downloader import (
|
||||
AppleMusicBaseDownloader,
|
||||
AppleMusicDownloader,
|
||||
AppleMusicMusicVideoDownloader,
|
||||
DownloadMode,
|
||||
RemuxFormatMusicVideo,
|
||||
RemuxMode,
|
||||
)
|
||||
from ..interface import (
|
||||
AppleMusicBaseInterface,
|
||||
AppleMusicInterface,
|
||||
AppleMusicMusicVideoInterface,
|
||||
AppleMusicSongInterface,
|
||||
AppleMusicUploadedVideoInterface,
|
||||
ArtistMediaType,
|
||||
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_create_sig = inspect.signature(AppleMusicApi.create)
|
||||
|
||||
base_interface_create_sig = inspect.signature(AppleMusicBaseInterface.create)
|
||||
song_interface_sig = inspect.signature(AppleMusicSongInterface.__init__)
|
||||
music_video_interface_sig = inspect.signature(AppleMusicMusicVideoInterface.__init__)
|
||||
uploaded_video_interface_sig = inspect.signature(
|
||||
AppleMusicUploadedVideoInterface.__init__
|
||||
)
|
||||
interface_create_sig = inspect.signature(AppleMusicInterface)
|
||||
|
||||
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
|
||||
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
|
||||
downloader_sig = inspect.signature(AppleMusicDownloader.__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",
|
||||
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",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
artist_auto_select: Annotated[
|
||||
ArtistMediaType | None,
|
||||
option(
|
||||
"--artist-auto-select",
|
||||
help="Automatically select artist content to download (only for artist URLs)",
|
||||
default=None,
|
||||
type=ArtistMediaType,
|
||||
),
|
||||
]
|
||||
database_path: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--database-path",
|
||||
help="Path to the SQLite database file for registering downloaded media",
|
||||
default=None,
|
||||
type=click.Path(
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
writable=True,
|
||||
resolve_path=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
no_config_file: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--no-config-file",
|
||||
"-n",
|
||||
help="Don't use a config file",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
# 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,
|
||||
),
|
||||
]
|
||||
language: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--language",
|
||||
"-l",
|
||||
help="Metadata language",
|
||||
default=api_create_sig.parameters["language"].default,
|
||||
),
|
||||
]
|
||||
# Base Interface specific options
|
||||
cover_format: Annotated[
|
||||
CoverFormat,
|
||||
option(
|
||||
"--cover-format",
|
||||
help="Cover format",
|
||||
default=base_interface_create_sig.parameters["cover_format"].default,
|
||||
type=CoverFormat,
|
||||
),
|
||||
]
|
||||
cover_size: Annotated[
|
||||
int,
|
||||
option(
|
||||
"--cover-size",
|
||||
help="Cover size in pixels",
|
||||
default=base_interface_create_sig.parameters["cover_size"].default,
|
||||
),
|
||||
]
|
||||
wvd_path: Annotated[
|
||||
str | None,
|
||||
option(
|
||||
"--wvd-path",
|
||||
help=".wvd file path",
|
||||
default=base_interface_create_sig.parameters["wvd_path"].default,
|
||||
type=click.Path(
|
||||
file_okay=False,
|
||||
dir_okay=True,
|
||||
writable=True,
|
||||
resolve_path=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
# Song Interface Options
|
||||
synced_lyrics_format: Annotated[
|
||||
SyncedLyricsFormat,
|
||||
option(
|
||||
"--synced-lyrics-format",
|
||||
help="Synced lyrics format",
|
||||
default=song_interface_sig.parameters["synced_lyrics_format"].default,
|
||||
type=SyncedLyricsFormat,
|
||||
),
|
||||
]
|
||||
song_codec_piority: Annotated[
|
||||
list[SongCodec],
|
||||
option(
|
||||
"--song-codec-priority",
|
||||
help="Comma-separated codec priority",
|
||||
default=song_interface_sig.parameters["codec_priority"].default,
|
||||
type=Csv(SongCodec),
|
||||
),
|
||||
]
|
||||
use_album_date: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--use-album-date",
|
||||
help="Use album release date for songs",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
# Music Video Interface Options
|
||||
music_video_resolution: Annotated[
|
||||
MusicVideoResolution,
|
||||
option(
|
||||
"--music-video-resolution",
|
||||
help="Max music video resolution",
|
||||
default=music_video_interface_sig.parameters["resolution"].default,
|
||||
type=MusicVideoResolution,
|
||||
),
|
||||
]
|
||||
music_video_codec_priority: Annotated[
|
||||
list[MusicVideoCodec],
|
||||
option(
|
||||
"--music-video-codec-priority",
|
||||
help="Comma-separated codec priority",
|
||||
default=music_video_interface_sig.parameters["codec_priority"].default,
|
||||
type=Csv(MusicVideoCodec),
|
||||
),
|
||||
]
|
||||
# Uploaded Video Interface Options
|
||||
uploaded_video_quality: Annotated[
|
||||
UploadedVideoQuality,
|
||||
option(
|
||||
"--uploaded-video-quality",
|
||||
help="Post video quality",
|
||||
default=uploaded_video_interface_sig.parameters["quality"].default,
|
||||
type=UploadedVideoQuality,
|
||||
),
|
||||
]
|
||||
# Base Downloader specific options
|
||||
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,
|
||||
),
|
||||
),
|
||||
]
|
||||
nm3u8dlre_path: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--nm3u8dlre-path",
|
||||
help="N_m3u8DL-RE executable path",
|
||||
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
|
||||
),
|
||||
]
|
||||
mp4decrypt_path: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--mp4decrypt-path",
|
||||
help="mp4decrypt executable path",
|
||||
default=base_downloader_sig.parameters["mp4decrypt_path"].default,
|
||||
),
|
||||
]
|
||||
ffmpeg_path: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--ffmpeg-path",
|
||||
help="FFmpeg executable path",
|
||||
default=base_downloader_sig.parameters["ffmpeg_path"].default,
|
||||
),
|
||||
]
|
||||
mp4box_path: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--mp4box-path",
|
||||
help="MP4Box executable path",
|
||||
default=base_downloader_sig.parameters["mp4box_path"].default,
|
||||
),
|
||||
]
|
||||
use_wrapper: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--use-wrapper",
|
||||
help="Use wrapper for decrypting songs",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
wrapper_decrypt_ip: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--wrapper-decrypt-ip",
|
||||
help="IP address and port for wrapper decryption",
|
||||
default=base_downloader_sig.parameters["wrapper_decrypt_ip"].default,
|
||||
),
|
||||
]
|
||||
download_mode: Annotated[
|
||||
DownloadMode,
|
||||
option(
|
||||
"--download-mode",
|
||||
help="Download mode",
|
||||
default=base_downloader_sig.parameters["download_mode"].default,
|
||||
type=DownloadMode,
|
||||
),
|
||||
]
|
||||
album_folder_template: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--album-folder-template",
|
||||
help="Album folder template",
|
||||
default=base_downloader_sig.parameters["album_folder_template"].default,
|
||||
),
|
||||
]
|
||||
compilation_folder_template: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--compilation-folder-template",
|
||||
help="Compilation folder template",
|
||||
default=base_downloader_sig.parameters[
|
||||
"compilation_folder_template"
|
||||
].default,
|
||||
),
|
||||
]
|
||||
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,
|
||||
),
|
||||
]
|
||||
playlist_folder_template: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--playlist-folder-template",
|
||||
help="Playlist folder template",
|
||||
default=base_downloader_sig.parameters["playlist_folder_template"].default,
|
||||
),
|
||||
]
|
||||
single_disc_file_template: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--single-disc-file-template",
|
||||
help="Single disc file template",
|
||||
default=base_downloader_sig.parameters["single_disc_file_template"].default,
|
||||
),
|
||||
]
|
||||
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,
|
||||
),
|
||||
]
|
||||
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,
|
||||
),
|
||||
]
|
||||
playlist_file_template: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--playlist-file-template",
|
||||
help="Playlist file template",
|
||||
default=base_downloader_sig.parameters["playlist_file_template"].default,
|
||||
),
|
||||
]
|
||||
date_tag_template: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--date-tag-template",
|
||||
help="Date tag template",
|
||||
default=base_downloader_sig.parameters["date_tag_template"].default,
|
||||
),
|
||||
]
|
||||
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),
|
||||
),
|
||||
]
|
||||
truncate: Annotated[
|
||||
int,
|
||||
option(
|
||||
"--truncate",
|
||||
help="Max filename length",
|
||||
default=base_downloader_sig.parameters["truncate"].default,
|
||||
),
|
||||
]
|
||||
# DownloaderMusicVideo specific options
|
||||
music_video_remux_mode: Annotated[
|
||||
RemuxMode,
|
||||
option(
|
||||
"--music-video-remux-mode",
|
||||
help="Remux mode",
|
||||
default=music_video_downloader_sig.parameters["remux_mode"].default,
|
||||
type=RemuxMode,
|
||||
),
|
||||
]
|
||||
music_video_remux_format: Annotated[
|
||||
RemuxFormatMusicVideo,
|
||||
option(
|
||||
"--music-video-remux-format",
|
||||
help="Music video remux format",
|
||||
default=music_video_downloader_sig.parameters["remux_format"].default,
|
||||
type=RemuxFormatMusicVideo,
|
||||
),
|
||||
]
|
||||
# Downloader specific options
|
||||
overwrite: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--overwrite",
|
||||
help="Overwrite existing files",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
save_cover: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--save-cover",
|
||||
"-s",
|
||||
help="Save cover as separate file",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
save_playlist: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--save-playlist",
|
||||
help="Save M3U8 playlist file",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
no_synced_lyrics: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--no-synced-lyrics",
|
||||
help="Don't download synced lyrics",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
synced_lyrics_only: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--synced-lyrics-only",
|
||||
help="Download only synced lyrics",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
+96
-33
@@ -1,11 +1,14 @@
|
||||
import configparser
|
||||
import typing
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import click.types as click_types
|
||||
|
||||
from .cli_config import CliConfig
|
||||
from .constants import EXCLUDED_CONFIG_FILE_PARAMS
|
||||
from .utils import Csv
|
||||
|
||||
|
||||
class ConfigFile:
|
||||
@@ -17,6 +20,7 @@ class ConfigFile:
|
||||
self.config_path = config_path
|
||||
self.section_name = section_name
|
||||
|
||||
self.click_context = click.get_current_context()
|
||||
self._read_config_file()
|
||||
|
||||
def _read_config_file(self) -> None:
|
||||
@@ -35,34 +39,44 @@ class ConfigFile:
|
||||
self.config.write(config_file)
|
||||
|
||||
def _serialize_param_default(self, param: click.Parameter) -> str:
|
||||
if not isinstance(param.default, (list, tuple)):
|
||||
param_default = [param.default]
|
||||
else:
|
||||
param_default = param.default
|
||||
|
||||
if not param_default:
|
||||
return ""
|
||||
|
||||
first = param_default[0]
|
||||
|
||||
if isinstance(first, Enum):
|
||||
return ",".join(str(item.value) for item in param_default)
|
||||
if isinstance(first, bool):
|
||||
return ",".join(str(item).lower() for item in param_default)
|
||||
if first is None:
|
||||
if param.default is None:
|
||||
return "null"
|
||||
|
||||
return ",".join(str(item) for item in param_default)
|
||||
if isinstance(param.type, Csv):
|
||||
return ",".join(
|
||||
item.value if hasattr(item, "value") else str(item)
|
||||
for item in param.default
|
||||
)
|
||||
|
||||
if isinstance(param.type, click_types.FuncParamType):
|
||||
return param.default.value
|
||||
|
||||
if isinstance(param.type, click_types.BoolParamType):
|
||||
return "true" if param.default else "false"
|
||||
|
||||
if isinstance(
|
||||
param.type,
|
||||
click_types.Choice
|
||||
| click_types.Path
|
||||
| click_types.StringParamType
|
||||
| click_types.IntParamType,
|
||||
):
|
||||
return str(param.default)
|
||||
|
||||
raise NotImplementedError(
|
||||
f"Serialization for parameter '{param.name}' of type "
|
||||
f"'{type(param.type)}' is not implemented."
|
||||
)
|
||||
|
||||
def _add_param_default_to_config(
|
||||
self,
|
||||
param: click.Parameter,
|
||||
) -> bool:
|
||||
if self.config[self.section_name].get(param.name):
|
||||
if self.config.has_option(self.section_name, param.name):
|
||||
return False
|
||||
|
||||
value = self._serialize_param_default(param)
|
||||
self.config[self.section_name][param.name] = value
|
||||
self.config.set(self.section_name, param.name, value)
|
||||
|
||||
return True
|
||||
|
||||
@@ -71,19 +85,24 @@ class ConfigFile:
|
||||
param: click.Parameter,
|
||||
) -> typing.Any:
|
||||
value = self.config[self.section_name].get(param.name)
|
||||
if value is None:
|
||||
return param.default
|
||||
|
||||
if value == "null":
|
||||
return None
|
||||
|
||||
return param.type_cast_value(None, value)
|
||||
if not isinstance(param.type, click_types.ParamType):
|
||||
raise NotImplementedError(
|
||||
f"Parsing for parameter '{param.name}' of type "
|
||||
f"'{type(param.type)}' is not implemented."
|
||||
)
|
||||
|
||||
def add_params_default_to_config(
|
||||
self,
|
||||
params: list[click.Parameter],
|
||||
) -> None:
|
||||
return param.type.convert(value, None, None)
|
||||
|
||||
def add_params_default_to_config(self) -> None:
|
||||
has_changes = False
|
||||
|
||||
for param in params:
|
||||
for param in self.click_context.command.params:
|
||||
if param.name in EXCLUDED_CONFIG_FILE_PARAMS:
|
||||
continue
|
||||
|
||||
@@ -92,13 +111,57 @@ class ConfigFile:
|
||||
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.click_context.command.params}
|
||||
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) -> None:
|
||||
for param in self.click_context.command.params:
|
||||
if (
|
||||
self.click_context.get_parameter_source(param.name)
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
continue
|
||||
|
||||
if self.config.has_option(self.section_name, param.name):
|
||||
self.click_context.params[param.name] = self._parse_param_from_config(
|
||||
param
|
||||
)
|
||||
|
||||
def get_cli_config(self) -> CliConfig:
|
||||
config_dict = {}
|
||||
for param in self.click_context.command.params:
|
||||
if param.name in {"help", "version"}:
|
||||
continue
|
||||
|
||||
config_dict[param.name] = self.click_context.params.get(
|
||||
param.name, param.default
|
||||
)
|
||||
return CliConfig(**config_dict)
|
||||
|
||||
def load(self) -> CliConfig:
|
||||
self.cleanup_unknown_params()
|
||||
self.add_params_default_to_config()
|
||||
self.update_params_from_config()
|
||||
return self.get_cli_config()
|
||||
|
||||
@staticmethod
|
||||
def loader(func):
|
||||
@wraps(func)
|
||||
def wrapper(cli_config: CliConfig):
|
||||
ctx = click.get_current_context()
|
||||
config_path = ctx.params.get("config_path")
|
||||
no_config_file = ctx.params.get("no_config_file")
|
||||
if config_path and not no_config_file:
|
||||
cli_config = ConfigFile(config_path).load()
|
||||
return func(cli_config)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path: Path):
|
||||
self.connection = sqlite3.connect(path)
|
||||
self.cursor = self.connection.cursor()
|
||||
self._create_tables()
|
||||
|
||||
def _create_tables(self) -> None:
|
||||
self.cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
def get(self, media_id: str) -> str | None:
|
||||
self.cursor.execute("SELECT path FROM media WHERE id = ?", (media_id,))
|
||||
row = self.cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def add(self, media_id: str, path: str) -> None:
|
||||
self.cursor.execute(
|
||||
"INSERT OR REPLACE INTO media (id, path) VALUES (?, ?)",
|
||||
(media_id, str(Path(path).absolute())),
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
def remove(self, media_id: str) -> None:
|
||||
self.cursor.execute("DELETE FROM media WHERE id = ?", (media_id,))
|
||||
self.connection.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
self.connection.close()
|
||||
|
||||
def flat_filter(self, media_metadata: dict) -> str | None:
|
||||
media_id = media_metadata["id"]
|
||||
result = self.get(media_id)
|
||||
|
||||
if not result:
|
||||
return None
|
||||
|
||||
return result if Path(result).exists() else None
|
||||
@@ -0,0 +1,232 @@
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
import m3u8
|
||||
from ..interface import ArtistMediaType
|
||||
|
||||
|
||||
class InteractivePrompts:
|
||||
def __init__(
|
||||
self,
|
||||
artist_auto_select: ArtistMediaType | None = None,
|
||||
):
|
||||
self.artist_auto_select = artist_auto_select
|
||||
|
||||
@staticmethod
|
||||
def millis_to_min_sec(millis) -> str:
|
||||
minutes, seconds = divmod(millis // 1000, 60)
|
||||
return f"{minutes:02}:{seconds:02}"
|
||||
|
||||
@staticmethod
|
||||
async def ask_song_codec(
|
||||
playlists: list[dict],
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["stream_info"]["audio"],
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlists
|
||||
]
|
||||
|
||||
return await inquirer.select(
|
||||
message="Select which codec to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
@staticmethod
|
||||
async def ask_music_video_video_codec_function(
|
||||
playlists: list[m3u8.Playlist],
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
playlist.stream_info.codecs[:4],
|
||||
"x".join(str(v) for v in playlist.stream_info.resolution),
|
||||
str(playlist.stream_info.bandwidth),
|
||||
]
|
||||
),
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlists
|
||||
]
|
||||
|
||||
return await inquirer.select(
|
||||
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
@staticmethod
|
||||
async def ask_music_video_audio_codec_function(
|
||||
playlists: list[dict],
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["group_id"],
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlists
|
||||
]
|
||||
|
||||
selected = await inquirer.select(
|
||||
message="Select which audio codec to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
@staticmethod
|
||||
async def ask_uploaded_video_quality_function(
|
||||
available_qualities: dict[str, str],
|
||||
) -> str:
|
||||
qualities = list(available_qualities.keys())
|
||||
choices = [
|
||||
Choice(
|
||||
name=quality,
|
||||
value=quality,
|
||||
)
|
||||
for quality in qualities
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which quality to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return available_qualities[selected]
|
||||
|
||||
async def ask_artist_media_type(
|
||||
self,
|
||||
media_types: list[ArtistMediaType],
|
||||
artist_metadata: dict,
|
||||
) -> ArtistMediaType:
|
||||
if self.artist_auto_select:
|
||||
return self.artist_auto_select
|
||||
|
||||
available_choices = []
|
||||
for media_types in media_types:
|
||||
available_choices.append(
|
||||
Choice(
|
||||
name=str(media_types),
|
||||
value=(media_types,),
|
||||
),
|
||||
)
|
||||
|
||||
(media_type,) = await inquirer.select(
|
||||
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
|
||||
choices=available_choices,
|
||||
validate=lambda result: artist_metadata.get(result[0].path_key[0], {})
|
||||
.get(result[0].path_key[1], {})
|
||||
.get("data"),
|
||||
).execute_async()
|
||||
|
||||
return media_type
|
||||
|
||||
async def ask_artist_select_items(
|
||||
self,
|
||||
media_type: ArtistMediaType,
|
||||
items: list[dict],
|
||||
) -> list[dict]:
|
||||
if media_type in {
|
||||
ArtistMediaType.MAIN_ALBUMS,
|
||||
ArtistMediaType.COMPILATION_ALBUMS,
|
||||
ArtistMediaType.LIVE_ALBUMS,
|
||||
ArtistMediaType.SINGLES_EPS,
|
||||
ArtistMediaType.ALL_ALBUMS,
|
||||
}:
|
||||
return await self._ask_artist_select_albums(items)
|
||||
elif media_type == ArtistMediaType.TOP_SONGS:
|
||||
return await self._ask_artist_select_songs(
|
||||
items,
|
||||
)
|
||||
elif media_type == ArtistMediaType.MUSIC_VIDEOS:
|
||||
return await self._ask_artist_select_music_videos(items)
|
||||
|
||||
async def _ask_artist_select_albums(
|
||||
self,
|
||||
albums: list[dict],
|
||||
) -> list[dict]:
|
||||
if self.artist_auto_select:
|
||||
return albums
|
||||
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
f'{album["attributes"]["trackCount"]:03d}',
|
||||
f'{album["attributes"]["releaseDate"]:<10}',
|
||||
f'{album["attributes"].get("contentRating", "None").title():<8}',
|
||||
f'{album["attributes"]["name"]}',
|
||||
]
|
||||
),
|
||||
value=album,
|
||||
)
|
||||
for album in albums
|
||||
if album.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
async def _ask_artist_select_songs(
|
||||
self,
|
||||
songs: list[dict],
|
||||
) -> list[dict]:
|
||||
if self.artist_auto_select:
|
||||
return songs
|
||||
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(song["attributes"]["durationInMillis"]),
|
||||
f'{song["attributes"].get("contentRating", "None").title():<8}',
|
||||
song["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=song,
|
||||
)
|
||||
for song in songs
|
||||
if song.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which songs to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
async def _ask_artist_select_music_videos(
|
||||
self,
|
||||
music_videos: list[dict],
|
||||
) -> list[dict]:
|
||||
if self.artist_auto_select:
|
||||
return music_videos
|
||||
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(
|
||||
music_video["attributes"]["durationInMillis"]
|
||||
),
|
||||
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
|
||||
music_video["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=music_video,
|
||||
)
|
||||
for music_video in music_videos
|
||||
if music_video.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which music videos to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
+59
-101
@@ -1,29 +1,26 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import typing
|
||||
from functools import wraps
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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,101 +39,62 @@ class Csv(click.ParamType):
|
||||
return result
|
||||
|
||||
|
||||
class PathPrompt(click.ParamType):
|
||||
name = "path"
|
||||
def custom_structlog_formatter(
|
||||
logger: Any,
|
||||
name: str,
|
||||
event_dict: dict[str, Any],
|
||||
) -> str:
|
||||
level = event_dict.get("level", "INFO").upper()
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
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 = {
|
||||
logging.DEBUG: dict(dim=True),
|
||||
logging.INFO: dict(fg="green"),
|
||||
logging.WARNING: dict(fg="yellow"),
|
||||
logging.ERROR: dict(fg="red"),
|
||||
logging.CRITICAL: dict(fg="red", bold=True),
|
||||
level_colors = {
|
||||
"DEBUG": "cyan",
|
||||
"INFO": "green",
|
||||
"WARNING": "yellow",
|
||||
"ERROR": "red",
|
||||
"CRITICAL": "red",
|
||||
}
|
||||
date_format = "%H:%M:%S"
|
||||
|
||||
def __init__(self, use_colors: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.use_colors = use_colors
|
||||
color = level_colors.get(level, "white")
|
||||
prefix = click.style(f"[{level:<8} {timestamp}]", fg=color)
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return logging.Formatter(
|
||||
(
|
||||
click.style(self.base_format, **self.format_colors.get(record.levelno))
|
||||
if self.use_colors
|
||||
else self.base_format
|
||||
action = event_dict.pop("action", None)
|
||||
if action:
|
||||
prefix += click.style(f" [{action}]", dim=True)
|
||||
|
||||
if level in {"INFO", "WARNING", "ERROR", "CRITICAL"}:
|
||||
message = event_dict.get("event", "")
|
||||
return f"{prefix} {message}"
|
||||
else:
|
||||
return f"{prefix} {event_dict}"
|
||||
|
||||
|
||||
def prompt_path(
|
||||
input_path: str,
|
||||
is_dir: bool = False,
|
||||
) -> str:
|
||||
path_validator = click.Path(
|
||||
exists=True,
|
||||
file_okay=not is_dir,
|
||||
dir_okay=is_dir,
|
||||
)
|
||||
path_type = "directory" if is_dir else "file"
|
||||
|
||||
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,
|
||||
)
|
||||
+ " %(message)s",
|
||||
datefmt=self.date_format,
|
||||
).format(record)
|
||||
input_path = input_path.strip('"')
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
parsed_params = config_file.parse_params_from_config(
|
||||
[
|
||||
param
|
||||
for param in ctx.command.params
|
||||
if ctx.get_parameter_source(param.name)
|
||||
!= click.core.ParameterSource.COMMANDLINE
|
||||
]
|
||||
)
|
||||
ctx.params.update(parsed_params)
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def make_sync(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
return asyncio.run(func(*args, **kwargs))
|
||||
|
||||
return wrapper
|
||||
return result_path
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from .amdecrypt import decrypt_file, decrypt_file_hex
|
||||
from .base import AppleMusicBaseDownloader
|
||||
from .downloader import AppleMusicDownloader
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .downloader_music_video import AppleMusicMusicVideoDownloader
|
||||
from .downloader_song import AppleMusicSongDownloader
|
||||
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
from .enums import *
|
||||
from .exceptions import *
|
||||
from .music_video import AppleMusicMusicVideoDownloader
|
||||
from .song import AppleMusicSongDownloader
|
||||
from .types import *
|
||||
from .uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,369 @@
|
||||
import asyncio
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from ..interface.interface import AppleMusicInterface
|
||||
from ..interface.types import MediaTags, PlaylistTags
|
||||
from ..utils import CustomStringFormatter, async_subprocess
|
||||
from .constants import ILLEGAL_CHAR_REPLACEMENT, ILLEGAL_CHARS_RE, TEMP_PATH_TEMPLATE
|
||||
from .enums import DownloadMode
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicBaseDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
interface: AppleMusicInterface,
|
||||
output_path: str = "./Apple Music",
|
||||
temp_path: str = ".",
|
||||
nm3u8dlre_path: str = "N_m3u8DL-RE",
|
||||
mp4decrypt_path: str = "mp4decrypt",
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
mp4box_path: str = "MP4Box",
|
||||
use_wrapper: bool = False,
|
||||
wrapper_decrypt_ip: str = "127.0.0.1:10020",
|
||||
download_mode: DownloadMode = DownloadMode.YTDLP,
|
||||
album_folder_template: str = "{album_artist}/{album}",
|
||||
compilation_folder_template: str = "Compilations/{album}",
|
||||
no_album_folder_template: str = "{artist}/Unknown Album",
|
||||
playlist_folder_template: str = "Playlists/{playlist_artist}",
|
||||
single_disc_file_template: str = "{track:02d} {title}",
|
||||
multi_disc_file_template: str = "{disc}-{track:02d} {title}",
|
||||
no_album_file_template: str = "{title}",
|
||||
playlist_file_template: str = "{playlist_title}",
|
||||
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
|
||||
exclude_tags: list[str] = None,
|
||||
truncate: int = None,
|
||||
silent: bool = False,
|
||||
):
|
||||
self.interface = interface
|
||||
self.output_path = output_path
|
||||
self.temp_path = temp_path
|
||||
self.nm3u8dlre_path = nm3u8dlre_path
|
||||
self.mp4decrypt_path = mp4decrypt_path
|
||||
self.ffmpeg_path = ffmpeg_path
|
||||
self.mp4box_path = mp4box_path
|
||||
self.use_wrapper = use_wrapper
|
||||
self.wrapper_decrypt_ip = wrapper_decrypt_ip
|
||||
self.download_mode = download_mode
|
||||
self.album_folder_template = album_folder_template
|
||||
self.compilation_folder_template = compilation_folder_template
|
||||
self.no_album_folder_template = no_album_folder_template
|
||||
self.single_disc_file_template = single_disc_file_template
|
||||
self.multi_disc_file_template = multi_disc_file_template
|
||||
self.playlist_folder_template = playlist_folder_template
|
||||
self.no_album_file_template = no_album_file_template
|
||||
self.playlist_file_template = playlist_file_template
|
||||
self.date_tag_template = date_tag_template
|
||||
self.exclude_tags = exclude_tags
|
||||
self.truncate = truncate
|
||||
self.silent = silent
|
||||
|
||||
self._initialize_binary_paths()
|
||||
|
||||
def _initialize_binary_paths(self):
|
||||
log = logger.bind(action="initialize_binary_paths")
|
||||
|
||||
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
|
||||
self.full_mp4decrypt_path = shutil.which(self.mp4decrypt_path)
|
||||
self.full_ffmpeg_path = shutil.which(self.ffmpeg_path)
|
||||
self.full_mp4box_path = shutil.which(self.mp4box_path)
|
||||
|
||||
log = log.debug(
|
||||
"success",
|
||||
full_nm3u8dlre_path=self.full_nm3u8dlre_path,
|
||||
full_mp4decrypt_path=self.full_mp4decrypt_path,
|
||||
full_ffmpeg_path=self.full_ffmpeg_path,
|
||||
full_mp4box_path=self.full_mp4box_path,
|
||||
)
|
||||
|
||||
def get_temp_path(
|
||||
self,
|
||||
media_id: str,
|
||||
folder_tag: str,
|
||||
file_tag: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
log = logger.bind(action="get_temp_path")
|
||||
|
||||
temp_path = str(
|
||||
Path(self.temp_path)
|
||||
/ TEMP_PATH_TEMPLATE.format(folder_tag)
|
||||
/ (f"{media_id}_{file_tag}" + file_extension)
|
||||
)
|
||||
|
||||
log.debug("success", temp_path=temp_path)
|
||||
|
||||
return temp_path
|
||||
|
||||
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 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:
|
||||
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 | None,
|
||||
) -> str:
|
||||
log = logger.bind(action="get_final_path")
|
||||
|
||||
if tags.album:
|
||||
template_folder_parts = (
|
||||
self.compilation_folder_template.split("/")
|
||||
if tags.compilation
|
||||
else self.album_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_file_parts = self.no_album_file_template.split("/")
|
||||
|
||||
template_parts = template_folder_parts + template_file_parts
|
||||
formatted_parts = []
|
||||
|
||||
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.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.title if playlist_tags else None),
|
||||
"Unknown Playlist Title",
|
||||
),
|
||||
playlist_track=(
|
||||
(playlist_tags.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)
|
||||
|
||||
final_path = str(Path(self.output_path, *formatted_parts))
|
||||
|
||||
log.debug("success", final_path=final_path)
|
||||
|
||||
return final_path
|
||||
|
||||
async def download_stream(self, stream_url: str, download_path: str):
|
||||
log = logger.bind(
|
||||
action="download_stream", stream_url=stream_url, download_path=download_path
|
||||
)
|
||||
|
||||
if self.download_mode == DownloadMode.YTDLP:
|
||||
await self._download_ytdlp_async(stream_url, download_path)
|
||||
|
||||
if self.download_mode == DownloadMode.NM3U8DLRE:
|
||||
await self._download_nm3u8dlre(stream_url, download_path)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
async def _download_ytdlp_async(self, stream_url: str, download_path: str) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._download_ytdlp_sync,
|
||||
stream_url,
|
||||
download_path,
|
||||
)
|
||||
|
||||
def _download_ytdlp_sync(self, stream_url: str, download_path: str) -> None:
|
||||
with YoutubeDL(
|
||||
{
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"outtmpl": download_path,
|
||||
"allow_unplayable_formats": True,
|
||||
"overwrites": True,
|
||||
"fixup": "never",
|
||||
"noprogress": self.silent,
|
||||
"allowed_extractors": ["generic"],
|
||||
}
|
||||
) as ydl:
|
||||
ydl.download(stream_url)
|
||||
|
||||
async def _download_nm3u8dlre(self, stream_url: str, download_path: str):
|
||||
download_path_obj = Path(download_path)
|
||||
|
||||
download_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
await async_subprocess(
|
||||
self.full_nm3u8dlre_path,
|
||||
stream_url,
|
||||
"--binary-merge",
|
||||
"--no-log",
|
||||
"--log-level",
|
||||
"off",
|
||||
"--ffmpeg-binary-path",
|
||||
self.full_ffmpeg_path,
|
||||
"--save-name",
|
||||
download_path_obj.stem,
|
||||
"--save-dir",
|
||||
download_path_obj.parent,
|
||||
"--tmp-dir",
|
||||
download_path_obj.parent,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def apply_tags(
|
||||
self,
|
||||
media_path: str,
|
||||
tags: MediaTags,
|
||||
cover_bytes: bytes | None,
|
||||
):
|
||||
log = logger.bind(action="apply_tags", media_path=media_path)
|
||||
|
||||
exclude_tags = self.exclude_tags or []
|
||||
|
||||
filtered_tags = MediaTags(
|
||||
**{
|
||||
k: v
|
||||
for k, v in tags.__dict__.items()
|
||||
if v is not None and k not in exclude_tags
|
||||
}
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
def _apply_mp4_tags(
|
||||
self,
|
||||
media_path: str,
|
||||
tags: dict,
|
||||
cover_bytes: bytes | None,
|
||||
skip_tagging: bool,
|
||||
):
|
||||
mp4 = MP4(media_path)
|
||||
mp4.clear()
|
||||
|
||||
if not skip_tagging:
|
||||
if cover_bytes is not None:
|
||||
mp4["covr"] = [
|
||||
MP4Cover(
|
||||
data=cover_bytes,
|
||||
imageformat=(
|
||||
MP4Cover.FORMAT_JPEG
|
||||
if self.interface.base.cover_format == CoverFormat.JPG
|
||||
else MP4Cover.FORMAT_PNG
|
||||
),
|
||||
)
|
||||
]
|
||||
mp4.update(tags)
|
||||
|
||||
mp4.save()
|
||||
|
||||
async def _apply_cover(
|
||||
self,
|
||||
mp4: MP4,
|
||||
cover_bytes: bytes | None,
|
||||
) -> None:
|
||||
if cover_bytes is None:
|
||||
return
|
||||
|
||||
mp4["covr"] = [
|
||||
MP4Cover(
|
||||
data=cover_bytes,
|
||||
imageformat=(
|
||||
MP4Cover.FORMAT_JPEG
|
||||
if self.interface.base.cover_format == CoverFormat.JPG
|
||||
else MP4Cover.FORMAT_PNG
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
def get_playlist_file_path(
|
||||
self,
|
||||
tags: PlaylistTags,
|
||||
) -> str:
|
||||
log = logger.bind(action="get_playlist_file_path")
|
||||
|
||||
template_folder_parts = self.playlist_folder_template.split("/")
|
||||
template_file_parts = self.playlist_file_template.split("/")
|
||||
template_parts = template_folder_parts + template_file_parts
|
||||
formatted_parts = []
|
||||
|
||||
for i, part in enumerate(template_parts):
|
||||
is_folder = i < len(template_parts) - 1
|
||||
formatted_part = CustomStringFormatter().format(
|
||||
part,
|
||||
playlist_artist=(tags.artist, "Unknown Playlist Artist"),
|
||||
playlist_id=(tags.playlist_id, "Unknown Playlist ID"),
|
||||
playlist_title=(tags.title, "Unknown Playlist Title"),
|
||||
playlist_track=(tags.track, ""),
|
||||
)
|
||||
file_ext = None if is_folder else ".m3u"
|
||||
sanitized_formatted_part = self._sanitize_string(
|
||||
formatted_part,
|
||||
file_ext,
|
||||
)
|
||||
formatted_parts.append(sanitized_formatted_part)
|
||||
|
||||
final_path = str(Path(self.output_path, *formatted_parts))
|
||||
|
||||
log.debug("success", playlist_file_path=final_path)
|
||||
|
||||
return final_path
|
||||
@@ -1,32 +1,3 @@
|
||||
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 = "_"
|
||||
|
||||
SONG_MEDIA_TYPE = {"song", "songs", "library-songs"}
|
||||
ALBUM_MEDIA_TYPE = {"album", "albums", "library-albums"}
|
||||
MUSIC_VIDEO_MEDIA_TYPE = {"music-video", "music-videos", "library-music-videos"}
|
||||
ARTIST_MEDIA_TYPE = {"artist", "artists", "library-artists"}
|
||||
UPLOADED_VIDEO_MEDIA_TYPE = {"post", "uploaded-videos"}
|
||||
PLAYLIST_MEDIA_TYPE = {"playlist", "playlists", "library-playlists"}
|
||||
|
||||
VALID_URL_PATTERN = re.compile(
|
||||
r"https://music\.apple\.com"
|
||||
r"(?:"
|
||||
r"/(?P<storefront>[a-z]{2})"
|
||||
r"/(?P<type>artist|album|playlist|song|music-video|post)"
|
||||
r"(?:/(?P<slug>[^\s/]+))?"
|
||||
r"/(?P<id>[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]+)"
|
||||
r"(?:\?i=(?P<sub_id>[0-9]+))?"
|
||||
r"|"
|
||||
r"(?:/(?P<library_storefront>[a-z]{2}))?"
|
||||
r"/library/(?P<library_type>playlist|albums)"
|
||||
r"/(?P<library_id>p\.[a-zA-Z0-9]+|l\.[a-zA-Z0-9]+)"
|
||||
r")"
|
||||
)
|
||||
|
||||
+227
-406
@@ -1,448 +1,269 @@
|
||||
import asyncio
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
import structlog
|
||||
|
||||
from ..utils import safe_gather
|
||||
from .constants import (
|
||||
ALBUM_MEDIA_TYPE,
|
||||
ARTIST_MEDIA_TYPE,
|
||||
MUSIC_VIDEO_MEDIA_TYPE,
|
||||
PLAYLIST_MEDIA_TYPE,
|
||||
SONG_MEDIA_TYPE,
|
||||
UPLOADED_VIDEO_MEDIA_TYPE,
|
||||
VALID_URL_PATTERN,
|
||||
)
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .downloader_music_video import AppleMusicMusicVideoDownloader
|
||||
from .downloader_song import AppleMusicSongDownloader
|
||||
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
from ..interface.types import AppleMusicMedia
|
||||
|
||||
from .constants import TEMP_PATH_TEMPLATE
|
||||
from .enums import DownloadMode, RemuxMode
|
||||
from .exceptions import (
|
||||
MediaFormatNotAvailableError,
|
||||
MediaNotStreamableError,
|
||||
MediaDownloadConfigurationError,
|
||||
GamdlDownloaderDependencyNotFoundError,
|
||||
GamdlDownloaderFlatFilterExcludedError,
|
||||
GamdlDownloaderMediaFileExistsError,
|
||||
GamdlDownloaderSyncedLyricsOnlyError,
|
||||
)
|
||||
from .types import DownloadItem, UrlInfo
|
||||
from .music_video import AppleMusicMusicVideoDownloader
|
||||
from .song import AppleMusicSongDownloader
|
||||
from .types import DownloadItem
|
||||
from .uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
song_downloader: AppleMusicSongDownloader,
|
||||
music_video_downloader: AppleMusicMusicVideoDownloader,
|
||||
uploaded_video_downloader: AppleMusicUploadedVideoDownloader,
|
||||
skip_music_videos: bool = False,
|
||||
song: AppleMusicSongDownloader,
|
||||
music_video: AppleMusicMusicVideoDownloader,
|
||||
uploaded_video: AppleMusicUploadedVideoDownloader,
|
||||
overwrite: bool = False,
|
||||
save_cover: bool = False,
|
||||
save_playlist: bool = False,
|
||||
no_synced_lyrics: bool = False,
|
||||
synced_lyrics_only: bool = False,
|
||||
skip_cleanup: bool = False,
|
||||
skip_processing: bool = False,
|
||||
):
|
||||
self.base_downloader = base_downloader
|
||||
self.song_downloader = song_downloader
|
||||
self.music_video_downloader = music_video_downloader
|
||||
self.uploaded_video_downloader = uploaded_video_downloader
|
||||
self.skip_music_videos = skip_music_videos
|
||||
self.song = song
|
||||
self.music_video = music_video
|
||||
self.uploaded_video = uploaded_video
|
||||
self.overwrite = overwrite
|
||||
self.save_cover = save_cover
|
||||
self.save_playlist = save_playlist
|
||||
self.no_synced_lyrics = no_synced_lyrics
|
||||
self.synced_lyrics_only = synced_lyrics_only
|
||||
self.skip_cleanup = skip_cleanup
|
||||
self.skip_processing = skip_processing
|
||||
|
||||
async def get_single_download_item(
|
||||
self.base = song.base
|
||||
|
||||
async def get_download_item_from_url(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
url: str,
|
||||
) -> AsyncGenerator[DownloadItem, None]:
|
||||
async for media in self.base.interface.get_media_from_url(url):
|
||||
yield await self.parse_download_item(media)
|
||||
|
||||
async def parse_download_item(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> DownloadItem:
|
||||
download_item = None
|
||||
if media.error or media.flat_filter_result:
|
||||
return DownloadItem(media)
|
||||
|
||||
if media_metadata["type"] in SONG_MEDIA_TYPE:
|
||||
download_item = await self.song_downloader.get_download_item(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
elif media.media_metadata["type"] in {"songs", "library-songs"}:
|
||||
return await self.song.get_download_item(media)
|
||||
|
||||
if media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
download_item = await self.music_video_downloader.get_download_item(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
elif media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
}:
|
||||
return await self.music_video.get_download_item(media)
|
||||
|
||||
if media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
|
||||
download_item = await self.uploaded_video_downloader.get_download_item(
|
||||
media_metadata,
|
||||
)
|
||||
elif media.media_metadata["type"] in {"uploaded-videos"}:
|
||||
return await self.uploaded_video.get_download_item(media)
|
||||
|
||||
return download_item
|
||||
|
||||
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"],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
tasks = [
|
||||
asyncio.create_task(
|
||||
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"]
|
||||
]
|
||||
|
||||
download_items = await safe_gather(*tasks)
|
||||
return download_items
|
||||
|
||||
async def get_artist_download_items(
|
||||
self,
|
||||
artist_metadata: dict,
|
||||
) -> list[DownloadItem | Exception]:
|
||||
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(
|
||||
artist_metadata["relationships"][relationship],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
media_type = await inquirer.select(
|
||||
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
|
||||
choices=[
|
||||
Choice(
|
||||
name="Albums",
|
||||
value="albums",
|
||||
),
|
||||
Choice(
|
||||
name="Music Videos",
|
||||
value="music-videos",
|
||||
),
|
||||
],
|
||||
validate=lambda result: artist_metadata["relationships"]
|
||||
.get(result, {})
|
||||
.get("data"),
|
||||
invalid_message="The artist doesn't have any items of this type",
|
||||
).execute_async()
|
||||
|
||||
if media_type == "albums":
|
||||
return await self.get_artist_albums_download_items(
|
||||
artist_metadata["relationships"]["albums"]["data"]
|
||||
)
|
||||
if media_type == "music-videos":
|
||||
return await self.get_artist_music_videos_download_items(
|
||||
artist_metadata["relationships"]["music-videos"]["data"]
|
||||
)
|
||||
|
||||
async def get_artist_albums_download_items(
|
||||
self,
|
||||
albums_metadata: list[dict],
|
||||
) -> list[DownloadItem | Exception]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
f'{album["attributes"]["trackCount"]:03d}',
|
||||
f'{album["attributes"]["releaseDate"]:<10}',
|
||||
f'{album["attributes"].get("contentRating", "None").title():<8}',
|
||||
f'{album["attributes"]["name"]}',
|
||||
]
|
||||
),
|
||||
value=album,
|
||||
)
|
||||
for album in albums_metadata
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
|
||||
download_items = []
|
||||
|
||||
album_tasks = [
|
||||
asyncio.create_task(
|
||||
self.base_downloader.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])
|
||||
)
|
||||
for album_response in album_responses
|
||||
]
|
||||
track_results = await safe_gather(*track_tasks)
|
||||
|
||||
for track_result in track_results:
|
||||
download_items.extend(track_result)
|
||||
|
||||
return download_items
|
||||
|
||||
async def get_artist_music_videos_download_items(
|
||||
self,
|
||||
music_videos_metadata: list[dict],
|
||||
) -> list[DownloadItem | Exception]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(
|
||||
music_video["attributes"]["durationInMillis"]
|
||||
),
|
||||
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
|
||||
music_video["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=music_video,
|
||||
)
|
||||
for music_video in music_videos_metadata
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which music videos to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
|
||||
music_video_tasks = [
|
||||
asyncio.create_task(
|
||||
self.get_single_download_item(
|
||||
music_video_metadata,
|
||||
)
|
||||
)
|
||||
for music_video_metadata in selected
|
||||
]
|
||||
download_items = await safe_gather(*music_video_tasks)
|
||||
|
||||
return download_items
|
||||
|
||||
def millis_to_min_sec(self, millis) -> str:
|
||||
minutes, seconds = divmod(millis // 1000, 60)
|
||||
return f"{minutes:02}:{seconds:02}"
|
||||
|
||||
def get_url_info(self, url: str) -> UrlInfo | None:
|
||||
match = VALID_URL_PATTERN.match(url)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
return UrlInfo(
|
||||
**match.groupdict(),
|
||||
)
|
||||
|
||||
async def get_download_queue(
|
||||
self,
|
||||
url_info: UrlInfo,
|
||||
) -> list[DownloadItem | Exception] | None:
|
||||
return await self._get_download_queue(
|
||||
"song" if url_info.sub_id else url_info.type,
|
||||
url_info.sub_id or url_info.id or url_info.library_id,
|
||||
url_info.library_id is not None,
|
||||
)
|
||||
|
||||
async def _get_download_queue(
|
||||
self,
|
||||
url_type: str,
|
||||
id: str,
|
||||
is_library: bool,
|
||||
) -> list[DownloadItem | Exception] | None:
|
||||
download_items = []
|
||||
|
||||
if url_type in ARTIST_MEDIA_TYPE:
|
||||
artist_response = await self.base_downloader.apple_music_api.get_artist(
|
||||
id,
|
||||
)
|
||||
|
||||
if artist_response is None:
|
||||
return None
|
||||
|
||||
download_items = await self.get_artist_download_items(
|
||||
artist_response["data"][0],
|
||||
)
|
||||
|
||||
if url_type in SONG_MEDIA_TYPE:
|
||||
song_respose = await self.base_downloader.apple_music_api.get_song(id)
|
||||
|
||||
if song_respose is None:
|
||||
return None
|
||||
|
||||
download_items.append(
|
||||
await self.get_single_download_item(song_respose["data"][0])
|
||||
)
|
||||
|
||||
if url_type in ALBUM_MEDIA_TYPE:
|
||||
if is_library:
|
||||
album_response = (
|
||||
await self.base_downloader.apple_music_api.get_library_album(id)
|
||||
)
|
||||
else:
|
||||
album_response = await self.base_downloader.apple_music_api.get_album(
|
||||
id
|
||||
)
|
||||
|
||||
if album_response is None:
|
||||
return None
|
||||
|
||||
download_items = await self.get_collection_download_items(
|
||||
album_response["data"][0],
|
||||
)
|
||||
|
||||
if url_type in PLAYLIST_MEDIA_TYPE:
|
||||
if is_library:
|
||||
playlist_response = (
|
||||
await self.base_downloader.apple_music_api.get_library_playlist(id)
|
||||
)
|
||||
else:
|
||||
playlist_response = (
|
||||
await self.base_downloader.apple_music_api.get_playlist(id)
|
||||
)
|
||||
|
||||
if playlist_response is None:
|
||||
return None
|
||||
|
||||
download_items = await self.get_collection_download_items(
|
||||
playlist_response["data"][0],
|
||||
)
|
||||
|
||||
if url_type in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
music_video_response = (
|
||||
await self.base_downloader.apple_music_api.get_music_video(id)
|
||||
)
|
||||
|
||||
if music_video_response is None:
|
||||
return None
|
||||
|
||||
download_items.append(
|
||||
await self.get_single_download_item(music_video_response["data"][0])
|
||||
)
|
||||
|
||||
if url_type in UPLOADED_VIDEO_MEDIA_TYPE:
|
||||
uploaded_video = (
|
||||
await self.base_downloader.apple_music_api.get_uploaded_video(id)
|
||||
)
|
||||
|
||||
if uploaded_video is None:
|
||||
return None
|
||||
|
||||
download_items.append(
|
||||
await self.get_single_download_item(uploaded_video["data"][0])
|
||||
)
|
||||
|
||||
return download_items
|
||||
|
||||
async def download(self, download_item: DownloadItem | Exception) -> None:
|
||||
async def download(self, item: DownloadItem) -> None:
|
||||
try:
|
||||
if isinstance(download_item, Exception):
|
||||
raise download_item
|
||||
if item.media.error:
|
||||
raise item.media.error
|
||||
|
||||
await self._initial_processing(download_item)
|
||||
await self._download(download_item)
|
||||
await self._final_processing(download_item)
|
||||
if item.media.flat_filter_result:
|
||||
raise GamdlDownloaderFlatFilterExcludedError(
|
||||
item.media.media_metadata["id"]
|
||||
)
|
||||
|
||||
await self._initial_processing(item)
|
||||
await self._download(item)
|
||||
await self._final_processing(item)
|
||||
finally:
|
||||
if isinstance(download_item, DownloadItem):
|
||||
self.base_downloader.cleanup_temp(download_item.random_uuid)
|
||||
self._cleanup_temp(item.uuid_)
|
||||
|
||||
async def _download(
|
||||
def _update_playlist_file(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
playlist_file_path: str,
|
||||
final_path: str,
|
||||
playlist_track: int,
|
||||
) -> None:
|
||||
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"])
|
||||
log = logger.bind(
|
||||
action="update_playlist_file",
|
||||
playlist_file_path=playlist_file_path,
|
||||
final_path=final_path,
|
||||
playlist_track=playlist_track,
|
||||
)
|
||||
|
||||
if self.song_downloader.synced_lyrics_only:
|
||||
playlist_file_path_obj = Path(playlist_file_path)
|
||||
final_path_obj = Path(final_path)
|
||||
output_dir_obj = Path(self.base.output_path)
|
||||
|
||||
playlist_file_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
playlist_file_path_parent_parts_len = len(playlist_file_path_obj.parent.parts)
|
||||
output_path_parts_len = len(output_dir_obj.parts)
|
||||
|
||||
final_path_relative = Path(
|
||||
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
|
||||
*final_path_obj.parts[output_path_parts_len:],
|
||||
)
|
||||
playlist_file_lines = (
|
||||
playlist_file_path_obj.open("r", encoding="utf8").readlines()
|
||||
if playlist_file_path_obj.exists()
|
||||
else []
|
||||
)
|
||||
if len(playlist_file_lines) < playlist_track:
|
||||
playlist_file_lines.extend(
|
||||
"\n" for _ in range(playlist_track - len(playlist_file_lines))
|
||||
)
|
||||
|
||||
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
|
||||
with playlist_file_path_obj.open("w", encoding="utf8") as playlist_file:
|
||||
playlist_file.writelines(playlist_file_lines)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
def _write_cover(self, cover_path: str, cover_bytes: bytes) -> None:
|
||||
log = logger.bind(action="write_cover_file", cover_path=cover_path)
|
||||
|
||||
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(cover_path, "wb") as f:
|
||||
f.write(cover_bytes)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
def _write_synced_lyrics(self, synced_lyrics_path: str, lyrics: str) -> None:
|
||||
log = logger.bind(
|
||||
action="write_synced_lyrics",
|
||||
synced_lyrics_path=synced_lyrics_path,
|
||||
)
|
||||
|
||||
Path(synced_lyrics_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(synced_lyrics_path, "w", encoding="utf-8") as f:
|
||||
f.write(lyrics)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
async def _initial_processing(self, item: DownloadItem) -> None:
|
||||
if self.skip_processing:
|
||||
return
|
||||
|
||||
if download_item.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 item.playlist_file_path and item.final_path and self.save_playlist:
|
||||
self._update_playlist_file(
|
||||
item.playlist_file_path,
|
||||
item.final_path,
|
||||
item.media.playlist_tags.track,
|
||||
)
|
||||
|
||||
if (
|
||||
Path(download_item.final_path).exists()
|
||||
and not self.base_downloader.overwrite
|
||||
):
|
||||
raise FileExistsError(
|
||||
f'Media file already exists at "{download_item.final_path}"'
|
||||
if item.cover_path and self.save_cover and item.media.cover.url:
|
||||
cover_bytes = await self.base.interface.base.get_cover_bytes(
|
||||
item.media.cover.url,
|
||||
)
|
||||
|
||||
if 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:
|
||||
await self.song_downloader.download(download_item)
|
||||
|
||||
if download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
await self.music_video_downloader.download(download_item)
|
||||
|
||||
if download_item.media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
|
||||
await self.uploaded_video_downloader.download(download_item)
|
||||
|
||||
async def _initial_processing(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
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)
|
||||
if cover_bytes and (
|
||||
self.base_downloader.overwrite
|
||||
or not Path(download_item.cover_path).exists()
|
||||
):
|
||||
self.base_downloader.write_cover_image(
|
||||
if cover_bytes and (self.overwrite or not Path(item.cover_path).exists()):
|
||||
self._write_cover(
|
||||
item.cover_path,
|
||||
cover_bytes,
|
||||
download_item.cover_path,
|
||||
)
|
||||
|
||||
if (
|
||||
download_item.lyrics
|
||||
and download_item.lyrics.synced
|
||||
and not self.song_downloader.no_synced_lyrics
|
||||
and (
|
||||
self.base_downloader.overwrite
|
||||
or not Path(download_item.synced_lyrics_path).exists()
|
||||
)
|
||||
item.synced_lyrics_path
|
||||
and not self.no_synced_lyrics
|
||||
and item.media.lyrics
|
||||
and item.media.lyrics.synced
|
||||
and (self.overwrite or not Path(item.synced_lyrics_path).exists())
|
||||
):
|
||||
self.song_downloader.write_synced_lyrics(
|
||||
download_item.lyrics.synced,
|
||||
download_item.synced_lyrics_path,
|
||||
self._write_synced_lyrics(
|
||||
item.synced_lyrics_path,
|
||||
item.media.lyrics.synced,
|
||||
)
|
||||
|
||||
if download_item.playlist_tags and self.base_downloader.save_playlist:
|
||||
self.base_downloader.update_playlist_file(
|
||||
download_item.playlist_file_path,
|
||||
download_item.final_path,
|
||||
download_item.playlist_tags.playlist_track,
|
||||
)
|
||||
async def _download(self, item: DownloadItem) -> None:
|
||||
if item.media.error:
|
||||
raise item.media.error
|
||||
|
||||
if self.synced_lyrics_only:
|
||||
raise GamdlDownloaderSyncedLyricsOnlyError()
|
||||
|
||||
if Path(item.final_path).exists() and not self.overwrite:
|
||||
raise GamdlDownloaderMediaFileExistsError(item.final_path)
|
||||
|
||||
if item.media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
"songs",
|
||||
"library-songs",
|
||||
}:
|
||||
if (
|
||||
self.base.download_mode == DownloadMode.NM3U8DLRE
|
||||
and not self.base.full_nm3u8dlre_path
|
||||
):
|
||||
raise GamdlDownloaderDependencyNotFoundError("N_m3u8DL-RE")
|
||||
|
||||
if item.media.media_metadata["type"] in {"songs", "library-songs"}:
|
||||
await self.song.download(item)
|
||||
|
||||
elif item.media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
}:
|
||||
if not self.base.full_mp4decrypt_path:
|
||||
raise GamdlDownloaderDependencyNotFoundError("mp4decrypt")
|
||||
|
||||
if (
|
||||
self.music_video.remux_mode == RemuxMode.FFMPEG
|
||||
and not self.base.full_ffmpeg_path
|
||||
):
|
||||
raise GamdlDownloaderDependencyNotFoundError("FFmpeg")
|
||||
|
||||
if (
|
||||
self.music_video.remux_mode == RemuxMode.MP4BOX
|
||||
and not self.base.full_mp4box_path
|
||||
):
|
||||
raise GamdlDownloaderDependencyNotFoundError("MP4Box")
|
||||
|
||||
await self.music_video.download(item)
|
||||
|
||||
elif item.media.media_metadata["type"] in {"uploaded-videos"}:
|
||||
await self.uploaded_video.download(item)
|
||||
|
||||
def _move_to_final_path(self, staged_path: str, final_path: str) -> None:
|
||||
log = logger.bind(
|
||||
action="move_to_final_path",
|
||||
staged_path=staged_path,
|
||||
final_path=final_path,
|
||||
)
|
||||
|
||||
Path(final_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(staged_path, final_path)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
async def _final_processing(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
item: DownloadItem,
|
||||
) -> None:
|
||||
if download_item.staged_path and Path(download_item.staged_path).exists():
|
||||
self.base_downloader.move_to_final_path(
|
||||
download_item.staged_path,
|
||||
download_item.final_path,
|
||||
if self.skip_processing:
|
||||
return
|
||||
|
||||
if Path(item.staged_path).exists():
|
||||
self._move_to_final_path(
|
||||
item.staged_path,
|
||||
item.final_path,
|
||||
)
|
||||
|
||||
def _cleanup_temp(self, folder_tag: str) -> None:
|
||||
log = logger.bind(action="cleanup_temp", folder_tag=folder_tag)
|
||||
|
||||
temp_path = Path(self.base.temp_path) / TEMP_PATH_TEMPLATE.format(folder_tag)
|
||||
if temp_path.exists() and temp_path.is_dir() and not self.skip_cleanup:
|
||||
shutil.rmtree(temp_path, ignore_errors=True)
|
||||
log.debug("success")
|
||||
|
||||
@@ -1,449 +0,0 @@
|
||||
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.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 .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,
|
||||
overwrite: bool = False,
|
||||
save_cover: bool = False,
|
||||
save_playlist: bool = False,
|
||||
nm3u8dlre_path: str = "N_m3u8DL-RE",
|
||||
mp4decrypt_path: str = "mp4decrypt",
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
mp4box_path: str = "MP4Box",
|
||||
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",
|
||||
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",
|
||||
exclude_tags: list[str] = None,
|
||||
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
|
||||
self.overwrite = overwrite
|
||||
self.save_cover = save_cover
|
||||
self.save_playlist = save_playlist
|
||||
self.nm3u8dlre_path = nm3u8dlre_path
|
||||
self.mp4decrypt_path = mp4decrypt_path
|
||||
self.ffmpeg_path = ffmpeg_path
|
||||
self.mp4box_path = mp4box_path
|
||||
self.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.no_album_file_template = no_album_file_template
|
||||
self.playlist_file_template = playlist_file_template
|
||||
self.date_tag_template = date_tag_template
|
||||
self.exclude_tags = exclude_tags
|
||||
self.cover_size = cover_size
|
||||
self.truncate = truncate
|
||||
self.silent = silent
|
||||
self.skip_processing = skip_processing
|
||||
|
||||
def setup(self):
|
||||
self._setup_binary_paths()
|
||||
self._setup_cdm()
|
||||
self._setup_interface()
|
||||
|
||||
def _setup_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)
|
||||
|
||||
def _setup_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]
|
||||
|
||||
def is_media_streamable(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
) -> 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,
|
||||
media_metadata: dict,
|
||||
) -> PlaylistTags:
|
||||
playlist_track = (
|
||||
playlist_metadata["relationships"]["tracks"]["data"].index(media_metadata)
|
||||
+ 1
|
||||
)
|
||||
|
||||
return PlaylistTags(
|
||||
playlist_artist=playlist_metadata["attributes"].get(
|
||||
"curatorName", "Unknown"
|
||||
),
|
||||
playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
|
||||
playlist_title=playlist_metadata["attributes"]["name"],
|
||||
playlist_track=playlist_track,
|
||||
)
|
||||
|
||||
def get_temp_path(
|
||||
self,
|
||||
media_id: str,
|
||||
folder_tag: str,
|
||||
file_tag: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(
|
||||
Path(self.temp_path)
|
||||
/ TEMP_PATH_TEMPLATE.format(folder_tag)
|
||||
/ (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(
|
||||
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
|
||||
else:
|
||||
if self.truncate is not None:
|
||||
dirty_string = dirty_string[: self.truncate - 4]
|
||||
return dirty_string.strip()
|
||||
|
||||
def get_final_path(
|
||||
self,
|
||||
tags: MediaTags,
|
||||
file_extension: str,
|
||||
playlist_tags: PlaylistTags,
|
||||
) -> str:
|
||||
if tags.album is not None:
|
||||
template_folder = (
|
||||
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 = self.no_album_folder_template.split("/")
|
||||
template_file = self.no_album_file_template.split("/")
|
||||
|
||||
template_final = template_folder + template_file
|
||||
|
||||
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
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
async def download_stream(self, stream_url: str, download_path: str):
|
||||
if self.download_mode == DownloadMode.YTDLP:
|
||||
await self.download_ytdlp(stream_url, download_path)
|
||||
|
||||
if self.download_mode == DownloadMode.NM3U8DLRE:
|
||||
await self.download_nm3u8dlre(stream_url, download_path)
|
||||
|
||||
async def download_ytdlp(self, stream_url: str, download_path: str) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._download_ytdlp,
|
||||
stream_url,
|
||||
download_path,
|
||||
)
|
||||
|
||||
def _download_ytdlp(self, stream_url: str, download_path: str) -> None:
|
||||
with YoutubeDL(
|
||||
{
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"outtmpl": download_path,
|
||||
"allow_unplayable_formats": True,
|
||||
"overwrites": True,
|
||||
"fixup": "never",
|
||||
"noprogress": self.silent,
|
||||
"allowed_extractors": ["generic"],
|
||||
}
|
||||
) as ydl:
|
||||
ydl.download(stream_url)
|
||||
|
||||
async def download_nm3u8dlre(self, stream_url: str, download_path: str):
|
||||
download_path_obj = Path(download_path)
|
||||
|
||||
download_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
await async_subprocess(
|
||||
self.full_nm3u8dlre_path,
|
||||
stream_url,
|
||||
"--binary-merge",
|
||||
"--no-log",
|
||||
"--log-level",
|
||||
"off",
|
||||
"--ffmpeg-binary-path",
|
||||
self.full_ffmpeg_path,
|
||||
"--save-name",
|
||||
download_path_obj.stem,
|
||||
"--save-dir",
|
||||
download_path_obj.parent,
|
||||
"--tmp-dir",
|
||||
download_path_obj.parent,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def apply_tags(
|
||||
self,
|
||||
media_path: Path,
|
||||
tags: MediaTags,
|
||||
cover_url_template: str,
|
||||
):
|
||||
exclude_tags = self.exclude_tags or []
|
||||
|
||||
filtered_tags = MediaTags(
|
||||
**{
|
||||
k: v
|
||||
for k, v in tags.__dict__.items()
|
||||
if v is not None and k not in exclude_tags
|
||||
}
|
||||
)
|
||||
mp4_tags = filtered_tags.as_mp4_tags(self.date_tag_template)
|
||||
skip_tagging = "all" in exclude_tags
|
||||
|
||||
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)
|
||||
|
||||
mp4.save()
|
||||
|
||||
async def _apply_cover(
|
||||
self,
|
||||
mp4: MP4,
|
||||
cover_url_template: str,
|
||||
) -> 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
|
||||
|
||||
mp4["covr"] = [
|
||||
MP4Cover(
|
||||
data=cover_bytes,
|
||||
imageformat=(
|
||||
MP4Cover.FORMAT_JPEG
|
||||
if self.cover_format == CoverFormat.JPG
|
||||
else MP4Cover.FORMAT_PNG
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
def move_to_final_path(self, stage_path: str, final_path: str) -> None:
|
||||
Path(final_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(stage_path, final_path)
|
||||
|
||||
def write_cover_image(
|
||||
self,
|
||||
cover_bytes: bytes,
|
||||
cover_path: str,
|
||||
) -> None:
|
||||
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(cover_path).write_bytes(cover_bytes)
|
||||
|
||||
def get_playlist_file_path(
|
||||
self,
|
||||
tags: PlaylistTags,
|
||||
) -> str:
|
||||
template_file = self.playlist_file_template.split("/")
|
||||
tags_dict = tags.__dict__.copy()
|
||||
|
||||
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"
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def update_playlist_file(
|
||||
self,
|
||||
playlist_file_path: str,
|
||||
final_path: str,
|
||||
playlist_track: int,
|
||||
) -> None:
|
||||
playlist_file_path_obj = Path(playlist_file_path)
|
||||
final_path_obj = Path(final_path)
|
||||
output_dir_obj = Path(self.output_path)
|
||||
|
||||
playlist_file_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
playlist_file_path_parent_parts_len = len(playlist_file_path_obj.parent.parts)
|
||||
output_path_parts_len = len(output_dir_obj.parts)
|
||||
|
||||
final_path_relative = Path(
|
||||
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
|
||||
*final_path_obj.parts[output_path_parts_len:],
|
||||
)
|
||||
playlist_file_lines = (
|
||||
playlist_file_path_obj.open("r", encoding="utf8").readlines()
|
||||
if playlist_file_path_obj.exists()
|
||||
else []
|
||||
)
|
||||
if len(playlist_file_lines) < playlist_track:
|
||||
playlist_file_lines.extend(
|
||||
"\n" for _ in range(playlist_track - len(playlist_file_lines))
|
||||
)
|
||||
|
||||
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
|
||||
with playlist_file_path_obj.open("w", encoding="utf8") as playlist_file:
|
||||
playlist_file.writelines(playlist_file_lines)
|
||||
|
||||
def cleanup_temp(self, random_uuid: str) -> None:
|
||||
temp_folder = Path(self.temp_path) / TEMP_PATH_TEMPLATE.format(random_uuid)
|
||||
if temp_folder.exists():
|
||||
shutil.rmtree(temp_folder)
|
||||
@@ -1,279 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import MusicVideoCodec, MusicVideoResolution
|
||||
from ..interface.interface_music_video import AppleMusicMusicVideoInterface
|
||||
from ..interface.types import DecryptionKeyAv
|
||||
from ..utils import async_subprocess
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .enums import RemuxFormatMusicVideo, RemuxMode
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicMusicVideoDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
downloader: AppleMusicBaseDownloader,
|
||||
codec_priority: list[MusicVideoCodec] = [
|
||||
MusicVideoCodec.H264,
|
||||
MusicVideoCodec.H265,
|
||||
],
|
||||
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
|
||||
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
|
||||
):
|
||||
self.downloader = downloader
|
||||
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,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.downloader.full_mp4box_path,
|
||||
"-quiet",
|
||||
"-add",
|
||||
input_path_audio,
|
||||
"-add",
|
||||
input_path_video,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
output_path,
|
||||
silent=self.downloader.silent,
|
||||
)
|
||||
|
||||
async def remux_ffmpeg(
|
||||
self,
|
||||
input_path_video: str,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
decryption_key: str = None,
|
||||
):
|
||||
if decryption_key:
|
||||
key = [
|
||||
"-decryption_key",
|
||||
decryption_key,
|
||||
]
|
||||
else:
|
||||
key = []
|
||||
|
||||
await async_subprocess(
|
||||
self.downloader.full_ffmpeg_path,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
*key,
|
||||
"-i",
|
||||
input_path_video,
|
||||
"-i",
|
||||
input_path_audio,
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
"mov_text",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
output_path,
|
||||
silent=self.downloader.silent,
|
||||
)
|
||||
|
||||
async def decrypt_mp4decrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.downloader.full_mp4decrypt_path,
|
||||
"--key",
|
||||
f"1:{decryption_key}",
|
||||
input_path,
|
||||
output_path,
|
||||
silent=self.downloader.silent,
|
||||
)
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path_video: str,
|
||||
encrypted_path_audio: str,
|
||||
decrypted_path_video: str,
|
||||
decrypted_path_audio: str,
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
):
|
||||
await self.decrypt_mp4decrypt(
|
||||
encrypted_path_video,
|
||||
decrypted_path_video,
|
||||
decryption_key.video_track.key,
|
||||
)
|
||||
await self.decrypt_mp4decrypt(
|
||||
encrypted_path_audio,
|
||||
decrypted_path_audio,
|
||||
decryption_key.audio_track.key,
|
||||
)
|
||||
|
||||
if self.downloader.remux_mode == RemuxMode.MP4BOX:
|
||||
await self.remux_mp4box(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
staged_path,
|
||||
)
|
||||
else:
|
||||
await self.remux_ffmpeg(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
staged_path,
|
||||
)
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(Path(final_path).with_suffix(file_extension))
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = music_video_metadata
|
||||
|
||||
music_video_id = self.downloader.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,
|
||||
)
|
||||
)
|
||||
download_item.media_tags = await self.music_video_interface.get_tags(
|
||||
music_video_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
if playlist_metadata:
|
||||
download_item.playlist_tags = self.downloader.get_playlist_tags(
|
||||
playlist_metadata,
|
||||
music_video_metadata,
|
||||
)
|
||||
download_item.playlist_file_path = self.downloader.get_playlist_file_path(
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
|
||||
stream_info = await self.music_video_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 = decryption_key
|
||||
|
||||
download_item.random_uuid = self.downloader.get_random_uuid()
|
||||
download_item.staged_path = self.downloader.get_temp_path(
|
||||
music_video_id,
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
(
|
||||
"."
|
||||
+ (
|
||||
"mp4"
|
||||
if self.remux_format == RemuxFormatMusicVideo.MP4
|
||||
else download_item.stream_info.file_format.value
|
||||
)
|
||||
),
|
||||
)
|
||||
download_item.final_path = self.downloader.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(
|
||||
music_video_metadata,
|
||||
)
|
||||
cover_file_extension = await self.downloader.get_cover_file_extension(
|
||||
download_item.cover_url_template,
|
||||
)
|
||||
if cover_file_extension:
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
cover_file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
encrypted_path_video = self.downloader.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
encrypted_path_audio = self.downloader.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.downloader.download_stream(
|
||||
download_item.stream_info.video_track.stream_url,
|
||||
encrypted_path_video,
|
||||
)
|
||||
await self.downloader.download_stream(
|
||||
download_item.stream_info.audio_track.stream_url,
|
||||
encrypted_path_audio,
|
||||
)
|
||||
|
||||
decrypted_path_video = self.downloader.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"decrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
decrypted_path_audio = self.downloader.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"decrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path_video,
|
||||
encrypted_path_audio,
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
download_item.staged_path,
|
||||
download_item.decryption_key,
|
||||
)
|
||||
|
||||
await self.downloader.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
download_item.cover_url_template,
|
||||
)
|
||||
@@ -1,303 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import SongCodec, SyncedLyricsFormat
|
||||
from ..interface.interface_song import AppleMusicSongInterface
|
||||
from ..interface.types import DecryptionKeyAv
|
||||
from ..utils import async_subprocess
|
||||
from .constants import DEFAULT_SONG_DECRYPTION_KEY
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .enums import RemuxMode
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicSongDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
downloader: AppleMusicBaseDownloader,
|
||||
codec: SongCodec = SongCodec.AAC_LEGACY,
|
||||
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
|
||||
no_synced_lyrics: bool = False,
|
||||
synced_lyrics_only: bool = False,
|
||||
):
|
||||
self.downloader = downloader
|
||||
self.codec = codec
|
||||
self.synced_lyrics_format = synced_lyrics_format
|
||||
self.no_synced_lyrics = no_synced_lyrics
|
||||
self.synced_lyrics_only = synced_lyrics_only
|
||||
|
||||
def setup(self):
|
||||
self._setup_interface()
|
||||
|
||||
def _setup_interface(self):
|
||||
self.song_interface = AppleMusicSongInterface(self.downloader.interface)
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = song_metadata
|
||||
|
||||
song_id = self.downloader.interface.get_media_id_of_library_media(song_metadata)
|
||||
|
||||
download_item.lyrics = await self.song_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,
|
||||
download_item.lyrics.unsynced if download_item.lyrics else None,
|
||||
)
|
||||
|
||||
if playlist_metadata:
|
||||
download_item.playlist_tags = self.downloader.get_playlist_tags(
|
||||
playlist_metadata,
|
||||
song_metadata,
|
||||
)
|
||||
download_item.playlist_file_path = self.downloader.get_playlist_file_path(
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.final_path = self.downloader.get_final_path(
|
||||
download_item.media_tags,
|
||||
".m4a",
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
download_item.synced_lyrics_path = self.get_lyrics_synced_path(
|
||||
download_item.final_path,
|
||||
)
|
||||
|
||||
if self.synced_lyrics_only:
|
||||
return download_item
|
||||
|
||||
if self.codec.is_legacy():
|
||||
download_item.stream_info = (
|
||||
await self.song_interface.get_stream_info_legacy(
|
||||
webplayback,
|
||||
self.codec,
|
||||
)
|
||||
)
|
||||
download_item.decryption_key = (
|
||||
await self.song_interface.get_decryption_key_legacy(
|
||||
download_item.stream_info,
|
||||
self.downloader.cdm,
|
||||
)
|
||||
)
|
||||
else:
|
||||
download_item.stream_info = await self.song_interface.get_stream_info(
|
||||
song_metadata,
|
||||
self.codec,
|
||||
)
|
||||
if (
|
||||
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,
|
||||
)
|
||||
)
|
||||
else:
|
||||
download_item.decryption_key = None
|
||||
|
||||
download_item.cover_url_template = self.downloader.get_cover_url_template(
|
||||
song_metadata
|
||||
)
|
||||
|
||||
download_item.random_uuid = self.downloader.get_random_uuid()
|
||||
download_item.staged_path = self.downloader.get_temp_path(
|
||||
song_id,
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
"." + download_item.stream_info.file_format.value,
|
||||
)
|
||||
cover_file_extension = await self.downloader.get_cover_file_extension(
|
||||
download_item.cover_url_template,
|
||||
)
|
||||
if cover_file_extension:
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
cover_file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
def fix_key_id(self, input_path: str):
|
||||
count = 0
|
||||
with open(input_path, "rb+") as file:
|
||||
while data := file.read(4096):
|
||||
pos = file.tell()
|
||||
i = 0
|
||||
while tenc := max(0, data.find(b"tenc", i)):
|
||||
kid = tenc + 12
|
||||
file.seek(max(0, pos - 4096) + kid, 0)
|
||||
file.write(bytes.fromhex(f"{count:032}"))
|
||||
count += 1
|
||||
i = kid + 1
|
||||
file.seek(pos, 0)
|
||||
|
||||
async def remux_mp4box(self, input_path: str, output_path: str):
|
||||
await async_subprocess(
|
||||
self.downloader.full_mp4box_path,
|
||||
"-quiet",
|
||||
"-add",
|
||||
input_path,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
output_path,
|
||||
silent=self.downloader.silent,
|
||||
)
|
||||
|
||||
async def remux_ffmpeg(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str = None,
|
||||
):
|
||||
if decryption_key:
|
||||
key = [
|
||||
"-decryption_key",
|
||||
decryption_key,
|
||||
]
|
||||
else:
|
||||
key = []
|
||||
|
||||
await async_subprocess(
|
||||
self.downloader.full_ffmpeg_path,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
*key,
|
||||
"-i",
|
||||
input_path,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
output_path,
|
||||
silent=self.downloader.silent,
|
||||
)
|
||||
|
||||
async def decrypt_mp4decrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str,
|
||||
legacy: bool,
|
||||
):
|
||||
if legacy:
|
||||
keys = [
|
||||
"--key",
|
||||
f"1:{decryption_key}",
|
||||
]
|
||||
else:
|
||||
self.fix_key_id(input_path)
|
||||
keys = [
|
||||
"--key",
|
||||
"0" * 31 + "1" + f":{decryption_key}",
|
||||
"--key",
|
||||
"0" * 32 + f":{DEFAULT_SONG_DECRYPTION_KEY}",
|
||||
]
|
||||
|
||||
await async_subprocess(
|
||||
self.downloader.full_mp4decrypt_path,
|
||||
*keys,
|
||||
input_path,
|
||||
output_path,
|
||||
silent=self.downloader.silent,
|
||||
)
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path: str,
|
||||
decrypted_path: str,
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
codec: SongCodec,
|
||||
):
|
||||
if codec.is_legacy() and self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
await self.remux_ffmpeg(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
decryption_key.audio_track.key,
|
||||
)
|
||||
else:
|
||||
await self.decrypt_mp4decrypt(
|
||||
encrypted_path,
|
||||
decrypted_path,
|
||||
decryption_key.audio_track.key,
|
||||
codec.is_legacy(),
|
||||
)
|
||||
if self.downloader.remux_mode == RemuxMode.FFMPEG:
|
||||
await self.remux_ffmpeg(
|
||||
decrypted_path,
|
||||
staged_path,
|
||||
)
|
||||
else:
|
||||
await self.remux_mp4box(
|
||||
decrypted_path,
|
||||
staged_path,
|
||||
)
|
||||
|
||||
def get_lyrics_synced_path(self, final_path: str) -> str:
|
||||
return str(Path(final_path).with_suffix("." + self.synced_lyrics_format.value))
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(Path(final_path).parent / ("Cover" + file_extension))
|
||||
|
||||
def write_synced_lyrics(
|
||||
self,
|
||||
synced_lyrics: str,
|
||||
lyrics_synced_path: str,
|
||||
):
|
||||
Path(lyrics_synced_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(lyrics_synced_path).write_text(synced_lyrics, encoding="utf8")
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
if self.synced_lyrics_only:
|
||||
return
|
||||
|
||||
encrypted_path = self.downloader.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted",
|
||||
".m4a",
|
||||
)
|
||||
await self.downloader.download_stream(
|
||||
download_item.stream_info.audio_track.stream_url,
|
||||
encrypted_path,
|
||||
)
|
||||
|
||||
decrypted_path = self.downloader.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"decrypted",
|
||||
".m4a",
|
||||
)
|
||||
await self.stage(
|
||||
encrypted_path,
|
||||
decrypted_path,
|
||||
download_item.staged_path,
|
||||
download_item.decryption_key,
|
||||
self.codec,
|
||||
)
|
||||
|
||||
await self.downloader.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
download_item.cover_url_template,
|
||||
)
|
||||
@@ -1,85 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import UploadedVideoQuality
|
||||
from ..interface.interface_uploaded_video import AppleMusicUploadedVideoInterface
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
downloader: AppleMusicBaseDownloader,
|
||||
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
|
||||
):
|
||||
self.downloader = downloader
|
||||
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:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = uploaded_video_metadata
|
||||
|
||||
download_item.media_tags = self.uploaded_video_interface.get_tags(
|
||||
uploaded_video_metadata,
|
||||
)
|
||||
|
||||
download_item.stream_info = await self.uploaded_video_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(
|
||||
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.media_tags,
|
||||
Path(download_item.staged_path).suffix,
|
||||
None,
|
||||
)
|
||||
|
||||
download_item.cover_url_template = self.downloader.get_cover_url_template(
|
||||
uploaded_video_metadata,
|
||||
)
|
||||
cover_file_extension = await self.downloader.get_cover_file_extension(
|
||||
download_item.cover_url_template,
|
||||
)
|
||||
if cover_file_extension:
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
cover_file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
await self.downloader.download_ytdlp(
|
||||
download_item.stream_info.video_track.stream_url,
|
||||
download_item.staged_path,
|
||||
)
|
||||
await self.downloader.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
download_item.cover_url_template,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
class MediaNotStreamableError(Exception):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(
|
||||
f'Media with ID "{media_id}" is not streamable'.format(media_id=media_id)
|
||||
)
|
||||
from ..utils import GamdlError
|
||||
|
||||
|
||||
class MediaFormatNotAvailableError(Exception):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(
|
||||
f'Media with ID "{media_id}" is not available in the requested format'
|
||||
)
|
||||
class GamdlDownloaderError(GamdlError):
|
||||
pass
|
||||
|
||||
|
||||
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 GamdlDownloaderSyncedLyricsOnlyError(GamdlDownloaderError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Download mode is set to synced lyrics only")
|
||||
|
||||
|
||||
class GamdlDownloaderMediaFileExistsError(GamdlDownloaderError):
|
||||
def __init__(self, file_path: str) -> None:
|
||||
super().__init__(f"Media file already exists: {file_path}")
|
||||
|
||||
|
||||
class GamdlDownloaderDependencyNotFoundError(GamdlDownloaderError):
|
||||
def __init__(self, dependency_name: str) -> None:
|
||||
super().__init__(f"Required dependency not found: {dependency_name}")
|
||||
|
||||
|
||||
class GamdlDownloaderFlatFilterExcludedError(GamdlDownloaderError):
|
||||
def __init__(self, media_id: str) -> None:
|
||||
super().__init__(f"Media is excluded by flat filter: {media_id}")
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# Dumped from Android Studio Virtual Device running Android 9
|
||||
|
||||
HARDCODED_WVD = """V1ZEAgIDAASoMIIEpAIBAAKCAQEAwnCFAPXy4U1J7p1NohAS+xl040f5FBaE/59bPp301bGz0UGFT9VoEtY3vaeakKh/d319xTNvCSWsEDRaMmp/wSnMiEZUkkl04872jx2uHuR4k6KYuuJoqhsIo1TwUBueFZynHBUJzXQeW8Eb1tYAROGwp8W7r+b0RIjHC89RFnfVXpYlF5I6McktyzJNSOwlQbMqlVihfSUkv3WRd3HFmA0Oxay51CEIkoTlNTHVlzVyhov5eHCDSp7QENRgaaQ03jC/CcgFOoQymhsBtRCM0CQmfuAHjA9e77R6m/GJPy75G9fqoZM1RMzVDHKbKZPd3sFd0c0+77gLzW8cWEaaHwIDAQABAoIBAQCB2pN46MikHvHZIcTPDt0eRQoDH/YArGl2Lf7J+sOgU2U7wv49KtCug9IGHwDiyyUVsAFmycrF2RroV45FTUq0vi2SdSXV7Kjb20Ren/vBNeQw9M37QWmU8Sj7q6YyWb9hv5T69DHvvDTqIjVtbM4RMojAAxYti5hmjNIh2PrWfVYWhXxCQ/WqAjWLtZBM6Oww1byfr5I/wFogAKkgHi8wYXZ4LnIC8V7jLAhujlToOvMMC9qwcBiPKDP2FO+CPSXaqVhH+LPSEgLggnU3EirihgxovbLNAuDEeEbRTyR70B0lW19tLHixso4ZQa7KxlVUwOmrHSZf7nVuWqPpxd+BAoGBAPQLyJ1IeRavmaU8XXxfMdYDoc8+xB7v2WaxkGXb6ToX1IWPkbMz4yyVGdB5PciIP3rLZ6s1+ruuRRV0IZ98i1OuN5TSR56ShCGg3zkd5C4L/xSMAz+NDfYSDBdO8BVvBsw21KqSRUi1ctL7QiIvfedrtGb5XrE4zhH0gjXlU5qZAoGBAMv2segn0Jx6az4rqRa2Y7zRx4iZ77JUqYDBI8WMnFeR54uiioTQ+rOs3zK2fGIWlrn4ohco/STHQSUTB8oCOFLMx1BkOqiR+UyebO28DJY7+V9ZmxB2Guyi7W8VScJcIdpSOPyJFOWZQKXdQFW3YICD2/toUx/pDAJh1sEVQsV3AoGBANyyp1rthmvoo5cVbymhYQ08vaERDwU3PLCtFXu4E0Ow90VNn6Ki4ueXcv/gFOp7pISk2/yuVTBTGjCblCiJ1en4HFWekJwrvgg3Vodtq8Okn6pyMCHRqvWEPqD5hw6rGEensk0K+FMXnF6GULlfn4mgEkYpb+PvDhSYvQSGfkPJAoGAF/bAKFqlM/1eJEvU7go35bNwEiij9Pvlfm8y2L8Qj2lhHxLV240CJ6IkBz1Rl+S3iNohkT8LnwqaKNT3kVB5daEBufxMuAmOlOX4PmZdxDj/r6hDg8ecmjj6VJbXt7JDd/c5ItKoVeGPqu035dpJyE+1xPAY9CLZel4scTsiQTkCgYBt3buRcZMwnc4qqpOOQcXK+DWD6QvpkcJ55ygHYw97iP/lF4euwdHd+I5b+11pJBAao7G0fHX3eSjqOmzReSKboSe5L8ZLB2cAI8AsKTBfKHWmCa8kDtgQuI86fUfirCGdhdA9AVP2QXN2eNCuPnFWi0WHm4fYuUB5be2c18ucxAb9CAESmgsK3QMIAhIQ071yBlsbLoO2CSB9Ds0cmRif6uevBiKOAjCCAQoCggEBAMJwhQD18uFNSe6dTaIQEvsZdONH+RQWhP+fWz6d9NWxs9FBhU/VaBLWN72nmpCof3d9fcUzbwklrBA0WjJqf8EpzIhGVJJJdOPO9o8drh7keJOimLriaKobCKNU8FAbnhWcpxwVCc10HlvBG9bWAEThsKfFu6/m9ESIxwvPURZ31V6WJReSOjHJLcsyTUjsJUGzKpVYoX0lJL91kXdxxZgNDsWsudQhCJKE5TUx1Zc1coaL+Xhwg0qe0BDUYGmkNN4wvwnIBTqEMpobAbUQjNAkJn7gB4wPXu+0epvxiT8u+RvX6qGTNUTM1QxymymT3d7BXdHNPu+4C81vHFhGmh8CAwEAASjwIkgBUqoBCAEQABqBAQQlRbfiBNDb6eU6aKrsH5WJaYszTioXjPLrWN9dqyW0vwfT11kgF0BbCGkAXew2tLJJqIuD95cjJvyGUSN6VyhL6dp44fWEGDSBIPR0mvRq7bMP+m7Y/RLKf83+OyVJu/BpxivQGC5YDL9f1/A8eLhTDNKXs4Ia5DrmTWdPTPBL8SIgyfUtg3ofI+/I9Tf7it7xXpT0AbQBJfNkcNXGpO3JcBMSgAIL5xsXK5of1mMwAl6ygN1Gsj4aZ052otnwN7kXk12SMsXheWTZ/PYh2KRzmt9RPS1T8hyFx/Kp5VkBV2vTAqqWrGw/dh4URqiHATZJUlhO7PN5m2Kq1LVFdXjWSzP5XBF2S83UMe+YruNHpE5GQrSyZcBqHO0QrdPcU35GBT7S7+IJr2AAXvnjqnb8yrtpPWN2ZW/IWUJN2z4vZ7/HV4aj3OZhkxC1DIMNyvsusUKoQQuf8gwKiEe8cFwbwFSicywlFk9la2IPe8oFShcxAzHLCCn/TIYUAvEL3/4LgaZvqWm80qCPYbgIP5HT8hPYkKWJ4WYknEWK+3InbnkzteFfGrQFCq4CCAESEGnj6Ji7LD+4o7MoHYT4jBQYjtW+kQUijgIwggEKAoIBAQDY9um1ifBRIOmkPtDZTqH+CZUBbb0eK0Cn3NHFf8MFUDzPEz+emK/OTub/hNxCJCao//pP5L8tRNUPFDrrvCBMo7Rn+iUb+mA/2yXiJ6ivqcN9Cu9i5qOU1ygon9SWZRsujFFB8nxVreY5Lzeq0283zn1Cg1stcX4tOHT7utPzFG/ReDFQt0O/GLlzVwB0d1sn3SKMO4XLjhZdncrtF9jljpg7xjMIlnWJUqxDo7TQkTytJmUl0kcM7bndBLerAdJFGaXc6oSY4eNy/IGDluLCQR3KZEQsy/mLeV1ggQ44MFr7XOM+rd+4/314q/deQbjHqjWFuVr8iIaKbq+R63ShAgMBAAEo8CISgAMii2Mw6z+Qs1bvvxGStie9tpcgoO2uAt5Zvv0CDXvrFlwnSbo+qR71Ru2IlZWVSbN5XYSIDwcwBzHjY8rNr3fgsXtSJty425djNQtF5+J2jrAhf3Q2m7EI5aohZGpD2E0cr+dVj9o8x0uJR2NWR8FVoVQSXZpad3M/4QzBLNto/tz+UKyZwa7Sc/eTQc2+ZcDS3ZEO3lGRsH864Kf/cEGvJRBBqcpJXKfG+ItqEW1AAPptjuggzmZEzRq5xTGf6or+bXrKjCpBS9G1SOyvCNF1k5z6lG8KsXhgQxL6ADHMoulxvUIihyPY5MpimdXfUdEQ5HA2EqNiNVNIO4qP007jW51yAeThOry4J22xs8RdkIClOGAauLIl0lLA4flMzW+VfQl5xYxP0E5tuhn0h+844DslU8ZF7U1dU2QprIApffXD9wgAACk26Rggy8e96z8i86/+YYyZQkc9hIdCAERrgEYCEbByzONrdRDs1MrS/ch1moV5pJv63BIKvQHGvLkaFwoMY29tcGFueV9uYW1lEgd1bmtub3duGioKCm1vZGVsX25hbWUSHEFuZHJvaWQgU0RLIGJ1aWx0IGZvciB4ODZfNjQaGwoRYXJjaGl0ZWN0dXJlX25hbWUSBng4Nl82NBodCgtkZXZpY2VfbmFtZRIOZ2VuZXJpY194ODZfNjQaIAoMcHJvZHVjdF9uYW1lEhBzZGtfcGhvbmVfeDg2XzY0GmMKCmJ1aWxkX2luZm8SVUFuZHJvaWQvc2RrX3Bob25lX3g4Nl82NC9nZW5lcmljX3g4Nl82NDo5L1BTUjEuMTgwNzIwLjAxMi80OTIzMjE0OnVzZXJkZWJ1Zy90ZXN0LWtleXMaHgoUd2lkZXZpbmVfY2RtX3ZlcnNpb24SBjE0LjAuMBokCh9vZW1fY3J5cHRvX3NlY3VyaXR5X3BhdGNoX2xldmVsEgEwMg4QASAAKA0wAEAASABQAA=="""
|
||||
@@ -0,0 +1,213 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
|
||||
from ..utils import async_subprocess
|
||||
from .base import AppleMusicBaseDownloader
|
||||
from .enums import RemuxFormatMusicVideo, RemuxMode
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicMusicVideoDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseDownloader,
|
||||
remux_mode: RemuxMode = RemuxMode.FFMPEG,
|
||||
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
|
||||
):
|
||||
self.base = base
|
||||
self.remux_mode = remux_mode
|
||||
self.remux_format = remux_format
|
||||
|
||||
async def _remux_mp4box(
|
||||
self,
|
||||
input_path_video: str,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.base.full_mp4box_path,
|
||||
"-quiet",
|
||||
"-add",
|
||||
input_path_audio,
|
||||
"-add",
|
||||
input_path_video,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
output_path,
|
||||
silent=self.base.silent,
|
||||
)
|
||||
|
||||
async def _remux_ffmpeg(
|
||||
self,
|
||||
input_path_video: str,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.base.full_ffmpeg_path,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
input_path_video,
|
||||
"-i",
|
||||
input_path_audio,
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
"mov_text",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
output_path,
|
||||
silent=self.base.silent,
|
||||
)
|
||||
|
||||
async def _decrypt_mp4decrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.base.full_mp4decrypt_path,
|
||||
"--key",
|
||||
f"1:{decryption_key}",
|
||||
input_path,
|
||||
output_path,
|
||||
silent=self.base.silent,
|
||||
)
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path_video: str,
|
||||
encrypted_path_audio: str,
|
||||
decrypted_path_video: str,
|
||||
decrypted_path_audio: str,
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
):
|
||||
await self._decrypt_mp4decrypt(
|
||||
encrypted_path_video,
|
||||
decrypted_path_video,
|
||||
decryption_key.video_track.key,
|
||||
)
|
||||
await self._decrypt_mp4decrypt(
|
||||
encrypted_path_audio,
|
||||
decrypted_path_audio,
|
||||
decryption_key.audio_track.key,
|
||||
)
|
||||
|
||||
if self.remux_mode == RemuxMode.MP4BOX:
|
||||
await self._remux_mp4box(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
staged_path,
|
||||
)
|
||||
else:
|
||||
await self._remux_ffmpeg(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
staged_path,
|
||||
)
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(Path(final_path).with_suffix(file_extension))
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem(media)
|
||||
|
||||
download_item.staged_path = self.base.get_temp_path(
|
||||
media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"staged",
|
||||
"." + media.stream_info.file_format.value,
|
||||
)
|
||||
|
||||
download_item.final_path = self.base.get_final_path(
|
||||
media.tags,
|
||||
"." + media.stream_info.file_format.value,
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
if media.playlist_tags:
|
||||
download_item.playlist_file_path = self.base.get_playlist_file_path(
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
media.cover.file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
encrypted_path_video = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"encrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
encrypted_path_audio = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"encrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.video_track.stream_url,
|
||||
encrypted_path_video,
|
||||
)
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.audio_track.stream_url,
|
||||
encrypted_path_audio,
|
||||
)
|
||||
|
||||
decrypted_path_video = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"decrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
decrypted_path_audio = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"decrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path_video,
|
||||
encrypted_path_audio,
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
download_item.staged_path,
|
||||
download_item.media.decryption_key,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.base.interface.base.get_cover_bytes(
|
||||
download_item.media.cover.url
|
||||
)
|
||||
if self.base.interface.base.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.base.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media.tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
|
||||
from .amdecrypt import decrypt_file, decrypt_file_hex
|
||||
from .base import AppleMusicBaseDownloader
|
||||
from .types import DownloadItem
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicSongDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseDownloader,
|
||||
):
|
||||
self.base = base
|
||||
|
||||
async def get_download_item(self, media: AppleMusicMedia) -> DownloadItem:
|
||||
download_item = DownloadItem(media)
|
||||
|
||||
if media.stream_info:
|
||||
download_item.staged_path = self.base.get_temp_path(
|
||||
media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"staged",
|
||||
"." + media.stream_info.file_format.value,
|
||||
)
|
||||
|
||||
download_item.final_path = self.base.get_final_path(
|
||||
media.tags,
|
||||
".m4a",
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
if media.playlist_tags:
|
||||
download_item.playlist_file_path = self.base.get_playlist_file_path(
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.synced_lyrics_path = self.get_synced_lyrics_path(
|
||||
download_item.final_path
|
||||
)
|
||||
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
media.cover.file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def _decrypt_amdecrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
media_id: str,
|
||||
fairplay_key: str,
|
||||
) -> None:
|
||||
await decrypt_file(
|
||||
self.base.wrapper_decrypt_ip,
|
||||
media_id,
|
||||
fairplay_key,
|
||||
input_path,
|
||||
output_path,
|
||||
)
|
||||
|
||||
async def _decrypt_amdecrypt_hex(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str,
|
||||
legacy: bool = False,
|
||||
) -> None:
|
||||
await decrypt_file_hex(
|
||||
input_path,
|
||||
output_path,
|
||||
decryption_key,
|
||||
legacy=legacy,
|
||||
)
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path: str,
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
legacy: bool,
|
||||
media_id: str,
|
||||
fairplay_key: str,
|
||||
):
|
||||
log = logger.bind(
|
||||
action="stage_song",
|
||||
media_id=media_id,
|
||||
encrypted_path=encrypted_path,
|
||||
staged_path=staged_path,
|
||||
)
|
||||
|
||||
if self.base.use_wrapper and not legacy:
|
||||
await self._decrypt_amdecrypt(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
media_id,
|
||||
fairplay_key,
|
||||
)
|
||||
else:
|
||||
await self._decrypt_amdecrypt_hex(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
decryption_key.audio_track.key,
|
||||
legacy,
|
||||
)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
def get_synced_lyrics_path(self, final_path: str) -> str:
|
||||
log = logger.bind(action="get_synced_lyrics_path", final_path=final_path)
|
||||
|
||||
synced_lyrics_path = str(
|
||||
Path(final_path).with_suffix(
|
||||
"." + self.base.interface.song.synced_lyrics_format.value
|
||||
)
|
||||
)
|
||||
|
||||
log.debug("success", synced_lyrics_path=synced_lyrics_path)
|
||||
|
||||
return synced_lyrics_path
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
log = logger.bind(
|
||||
action="get_song_cover_path",
|
||||
final_path=final_path,
|
||||
file_extension=file_extension,
|
||||
)
|
||||
|
||||
cover_path = str(Path(final_path).parent / ("Cover" + file_extension))
|
||||
|
||||
log.debug("success", cover_path=cover_path)
|
||||
|
||||
return cover_path
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
encrypted_path = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"encrypted",
|
||||
".m4a",
|
||||
)
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.audio_track.stream_url,
|
||||
encrypted_path,
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path,
|
||||
download_item.staged_path,
|
||||
download_item.media.decryption_key,
|
||||
download_item.media.stream_info.audio_track.legacy,
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.media.stream_info.audio_track.fairplay_key,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.base.interface.base.get_cover_bytes(
|
||||
download_item.media.cover.url
|
||||
)
|
||||
if self.base.interface.base.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.base.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media.tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -1,38 +1,15 @@
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..interface.types import (
|
||||
DecryptionKeyAv,
|
||||
Lyrics,
|
||||
MediaTags,
|
||||
PlaylistTags,
|
||||
StreamInfoAv,
|
||||
)
|
||||
from ..interface.types import AppleMusicMedia
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadItem:
|
||||
media_metadata: dict = None
|
||||
random_uuid: str = None
|
||||
lyrics: Lyrics = None
|
||||
media_tags: MediaTags = None
|
||||
playlist_tags: PlaylistTags = None
|
||||
stream_info: StreamInfoAv = None
|
||||
decryption_key: DecryptionKeyAv = None
|
||||
cover_url_template: str = None
|
||||
media: AppleMusicMedia
|
||||
uuid_: str = uuid.uuid4().hex[:8]
|
||||
staged_path: str = None
|
||||
final_path: str = None
|
||||
playlist_file_path: str = None
|
||||
synced_lyrics_path: str = None
|
||||
cover_path: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UrlInfo:
|
||||
storefront: str = None
|
||||
type: str = None
|
||||
slug: str = None
|
||||
id: str = None
|
||||
sub_id: str = None
|
||||
library_storefront: str = None
|
||||
library_type: str = None
|
||||
library_id: str = None
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from ..interface.types import AppleMusicMedia
|
||||
from .base import AppleMusicBaseDownloader
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseDownloader,
|
||||
):
|
||||
self.base = base
|
||||
|
||||
def get_cover_path(self, final_path: str, file_extension: str) -> str:
|
||||
return str(Path(final_path).with_suffix(file_extension))
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem(media)
|
||||
|
||||
download_item.staged_path = self.base.get_temp_path(
|
||||
media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"staged",
|
||||
"." + media.stream_info.file_format.value,
|
||||
)
|
||||
|
||||
download_item.final_path = self.base.get_final_path(
|
||||
media.tags,
|
||||
"." + media.stream_info.file_format.value,
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
media.cover.file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
await self.base._download_ytdlp_async(
|
||||
download_item.media.stream_info.video_track.stream_url,
|
||||
download_item.staged_path,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.base.interface.base.get_cover_bytes(
|
||||
download_item.media.cover.url
|
||||
)
|
||||
if self.base.interface.base.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.base.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media.tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -1,6 +1,8 @@
|
||||
from .base import AppleMusicBaseInterface
|
||||
from .enums import *
|
||||
from .interface import *
|
||||
from .interface_music_video import *
|
||||
from .interface_song import *
|
||||
from .interface_uploaded_video import *
|
||||
from .exceptions import *
|
||||
from .interface import AppleMusicInterface
|
||||
from .music_video import AppleMusicMusicVideoInterface
|
||||
from .song import AppleMusicSongInterface
|
||||
from .types import *
|
||||
from .uploaded_video import AppleMusicUploadedVideoInterface
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import re
|
||||
from io import BytesIO
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from async_lru import alru_cache
|
||||
from PIL import Image
|
||||
from pywidevine import PSSH, Cdm, Device
|
||||
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||
|
||||
from gamdl.interface.wvd import WVD
|
||||
|
||||
from ..api.apple_music import AppleMusicApi
|
||||
from ..api.itunes import ItunesApi
|
||||
from .constants import IMAGE_FILE_EXTENSION_MAP
|
||||
from .enums import CoverFormat
|
||||
from .types import Cover, DecryptionKey, PlaylistTags
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicBaseInterface:
|
||||
def __init__(
|
||||
self,
|
||||
apple_music_api: AppleMusicApi,
|
||||
itunes_api: ItunesApi,
|
||||
cover_format: CoverFormat,
|
||||
cover_size: int,
|
||||
cdm: Cdm,
|
||||
) -> None:
|
||||
self.apple_music_api = apple_music_api
|
||||
self.itunes_api = itunes_api
|
||||
self.cover_format = cover_format
|
||||
self.cover_size = cover_size
|
||||
self.cdm = cdm
|
||||
|
||||
@staticmethod
|
||||
def create_cdm(wvd_path: str | None = None) -> Cdm:
|
||||
if wvd_path:
|
||||
cdm = Cdm.from_device(Device.load(wvd_path))
|
||||
else:
|
||||
cdm = Cdm.from_device(Device.loads(WVD))
|
||||
cdm.MAX_NUM_OF_SESSIONS = float("inf")
|
||||
|
||||
return cdm
|
||||
|
||||
@staticmethod
|
||||
def is_media_streamable(
|
||||
media_metadata: dict,
|
||||
) -> bool:
|
||||
return bool(media_metadata["attributes"].get("playParams"))
|
||||
|
||||
@staticmethod
|
||||
def parse_catalog_media_id(media_metadata: dict) -> str:
|
||||
play_params = media_metadata["attributes"].get("playParams", {})
|
||||
return play_params.get("catalogId", media_metadata["id"])
|
||||
|
||||
@staticmethod
|
||||
def parse_media_id_from_url(media_metadata: dict) -> str | None:
|
||||
media_url = media_metadata["attributes"].get("url")
|
||||
if media_url is None:
|
||||
return None
|
||||
|
||||
url_media_id = media_url.split("/")[-1].split("?")[0]
|
||||
|
||||
return url_media_id
|
||||
|
||||
@staticmethod
|
||||
def parse_date(date: str) -> datetime.datetime:
|
||||
return datetime.datetime.fromisoformat(date.split("Z")[0])
|
||||
|
||||
@staticmethod
|
||||
def reconstruct_pssh(pssh: str) -> bytes:
|
||||
pssh = pssh.split(",")[-1]
|
||||
|
||||
decoded_pssh = base64.b64decode(pssh)
|
||||
if len(decoded_pssh) > 30:
|
||||
return pssh
|
||||
|
||||
widevine_pssh_data = WidevinePsshData(
|
||||
algorithm=1,
|
||||
key_ids=[decoded_pssh],
|
||||
)
|
||||
|
||||
return widevine_pssh_data.SerializeToString()
|
||||
|
||||
@staticmethod
|
||||
async def get_response(
|
||||
url: str,
|
||||
valid_responses: list[int] = [200],
|
||||
) -> httpx.Response:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
try:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code in valid_responses:
|
||||
return e.response
|
||||
raise e
|
||||
|
||||
return response
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
apple_music_api: AppleMusicApi,
|
||||
cover_format: CoverFormat = CoverFormat.JPG,
|
||||
cover_size: int = 1200,
|
||||
itunes_api: ItunesApi | None = None,
|
||||
wvd_path: str | None = None,
|
||||
):
|
||||
itunes_api = itunes_api or await ItunesApi.create(
|
||||
storefront=apple_music_api.storefront,
|
||||
language=apple_music_api.language,
|
||||
)
|
||||
cdm = cls.create_cdm(wvd_path)
|
||||
|
||||
base = cls(
|
||||
apple_music_api=apple_music_api,
|
||||
itunes_api=itunes_api,
|
||||
cover_format=cover_format,
|
||||
cover_size=cover_size,
|
||||
cdm=cdm,
|
||||
)
|
||||
return base
|
||||
|
||||
@alru_cache()
|
||||
async def get_album_cached(
|
||||
self,
|
||||
album_id: int,
|
||||
) -> dict | None:
|
||||
return (await self.apple_music_api.get_album(album_id))["data"][0]
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
pssh: str,
|
||||
track_id: str,
|
||||
) -> DecryptionKey:
|
||||
log = logger.bind(action="get_decryption_key", track_id=track_id)
|
||||
|
||||
reconstructed_pssh = self.reconstruct_pssh(pssh)
|
||||
cdm_session = self.cdm.open()
|
||||
|
||||
try:
|
||||
pssh_obj = PSSH(reconstructed_pssh)
|
||||
|
||||
challenge = base64.b64encode(
|
||||
await asyncio.to_thread(
|
||||
self.cdm.get_license_challenge, cdm_session, pssh_obj
|
||||
)
|
||||
).decode()
|
||||
license = await self.apple_music_api.get_license_exchange(
|
||||
track_id,
|
||||
pssh,
|
||||
challenge,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.cdm.parse_license, cdm_session, license["license"]
|
||||
)
|
||||
decryption_key_info = next(
|
||||
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
)
|
||||
finally:
|
||||
self.cdm.close(cdm_session)
|
||||
|
||||
decryption_key = DecryptionKey(
|
||||
key=decryption_key_info.key.hex(),
|
||||
kid=decryption_key_info.kid.hex,
|
||||
)
|
||||
|
||||
log.debug("success", decryption_key=decryption_key)
|
||||
|
||||
return decryption_key
|
||||
|
||||
@alru_cache()
|
||||
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
|
||||
log = logger.bind(action="get_cover_bytes", cover_url=cover_url)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(cover_url)
|
||||
|
||||
if response.status_code == 404:
|
||||
log.debug("cover_not_found")
|
||||
return None
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return response.content
|
||||
|
||||
def _get_cover_template_url(self, metadata: dict) -> str:
|
||||
if self.cover_format == CoverFormat.RAW:
|
||||
cover_template_url = self._get_raw_cover_url(
|
||||
metadata["attributes"]["artwork"]["url"]
|
||||
)
|
||||
else:
|
||||
cover_template_url = metadata["attributes"]["artwork"]["url"]
|
||||
|
||||
return cover_template_url
|
||||
|
||||
def _get_raw_cover_url(self, cover_url_template: str) -> str:
|
||||
return re.sub(
|
||||
r"image/thumb/",
|
||||
"",
|
||||
re.sub(
|
||||
r"is1-ssl",
|
||||
"a1",
|
||||
cover_url_template,
|
||||
),
|
||||
)
|
||||
|
||||
@alru_cache()
|
||||
async def _get_cover_file_extension(
|
||||
self,
|
||||
cover_url: str,
|
||||
) -> str | None:
|
||||
log = logger.bind(action="get_cover_file_extension", cover_url=cover_url)
|
||||
if self.cover_format != CoverFormat.RAW:
|
||||
return f".{self.cover_format.value}"
|
||||
|
||||
cover_bytes = await self.get_cover_bytes(cover_url)
|
||||
if cover_bytes is None:
|
||||
log.debug("cover_bytes_empty")
|
||||
return None
|
||||
|
||||
image_obj = Image.open(BytesIO(cover_bytes))
|
||||
image_format = image_obj.format.lower()
|
||||
return IMAGE_FILE_EXTENSION_MAP.get(
|
||||
image_format,
|
||||
f".{image_format.lower()}",
|
||||
)
|
||||
|
||||
async def get_cover(
|
||||
self,
|
||||
metadata: dict,
|
||||
) -> str:
|
||||
log = logger.bind(
|
||||
action="get_cover", media_id=self.parse_catalog_media_id(metadata)
|
||||
)
|
||||
|
||||
template_url = self._get_cover_template_url(metadata)
|
||||
|
||||
if self.cover_format == CoverFormat.RAW:
|
||||
cover_url = template_url
|
||||
else:
|
||||
cover_url = re.sub(
|
||||
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
|
||||
f"/{self.cover_size}x{self.cover_size}bb.{self.cover_format.value}",
|
||||
template_url,
|
||||
)
|
||||
|
||||
cover_file_extension = await self._get_cover_file_extension(cover_url)
|
||||
|
||||
cover = Cover(
|
||||
template_url=template_url,
|
||||
url=cover_url,
|
||||
file_extension=cover_file_extension,
|
||||
)
|
||||
|
||||
log.debug("success", cover=cover)
|
||||
|
||||
return cover
|
||||
|
||||
@alru_cache()
|
||||
async def get_media_date(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> datetime.datetime | None:
|
||||
log = logger.bind(action="get_media_date", media_id=media_id)
|
||||
|
||||
lookup_result = await self.itunes_api.get_lookup_result(media_id)
|
||||
if not lookup_result["results"]:
|
||||
log.debug("no_media_id")
|
||||
return None
|
||||
|
||||
release_date = lookup_result["results"][0].get("releaseDate")
|
||||
if not release_date:
|
||||
log.debug("no_release_date")
|
||||
return None
|
||||
|
||||
parsed_date = self.parse_date(release_date)
|
||||
|
||||
log.debug("success", release_date=parsed_date)
|
||||
|
||||
return parsed_date
|
||||
|
||||
def get_playlist_tags(
|
||||
self,
|
||||
playlist_metadata: dict,
|
||||
playlist_track: int,
|
||||
) -> PlaylistTags:
|
||||
log = logger.bind(
|
||||
action="get_playlist_tags",
|
||||
playlist_id=playlist_metadata["id"],
|
||||
)
|
||||
|
||||
playlist_tags = PlaylistTags(
|
||||
artist=playlist_metadata["attributes"].get("curatorName", "Unknown"),
|
||||
playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
|
||||
title=playlist_metadata["attributes"]["name"],
|
||||
track=playlist_track,
|
||||
)
|
||||
|
||||
log.debug("success", playlist_tags=playlist_tags)
|
||||
|
||||
return playlist_tags
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
MEDIA_TYPE_STR_MAP = {
|
||||
1: "Song",
|
||||
6: "Music Video",
|
||||
@@ -55,3 +57,42 @@ UPLOADED_VIDEO_QUALITY_RANK = [
|
||||
"sd480pVideo",
|
||||
"provisionalUploadVideo",
|
||||
]
|
||||
|
||||
IMAGE_FILE_EXTENSION_MAP = {
|
||||
"jpeg": ".jpg",
|
||||
"tiff": ".tif",
|
||||
}
|
||||
|
||||
VALID_URL_PATTERN = re.compile(
|
||||
r"https://(?:classical\.)?music\.apple\.com"
|
||||
r"(?:"
|
||||
r"/(?P<storefront>[a-z]{2})"
|
||||
r"/(?P<type>artist|album|playlist|song|music-video|post)"
|
||||
r"(?:/(?P<slug>[^\s/]+))?"
|
||||
r"/(?P<id>[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]+)"
|
||||
r"(?:\?i=(?P<sub_id>[0-9]+))?"
|
||||
r"|"
|
||||
r"(?:/(?P<library_storefront>[a-z]{2}))?"
|
||||
r"/library/(?P<library_type>playlist|albums)"
|
||||
r"/(?P<library_id>p\.[a-zA-Z0-9]+|l\.[a-zA-Z0-9]+)"
|
||||
r")"
|
||||
)
|
||||
|
||||
ARTIST_AUTO_SELECT_KEY_MAP = {
|
||||
"main-albums": ("views", "full-albums"),
|
||||
"compilation-albums": ("views", "compilation-albums"),
|
||||
"live-albums": ("views", "live-albums"),
|
||||
"singles-eps": ("views", "singles"),
|
||||
"all-albums": ("relationships", "albums"),
|
||||
"top-songs": ("views", "top-songs"),
|
||||
"music-videos": ("relationships", "music-videos"),
|
||||
}
|
||||
ARTIST_AUTO_SELECT_STR_MAP = {
|
||||
"main-albums": "Main Albums",
|
||||
"compilation-albums": "Compilation Albums",
|
||||
"live-albums": "Live Albums",
|
||||
"singles-eps": "Singles & EPs",
|
||||
"all-albums": "All Albums",
|
||||
"top-songs": "Top Songs",
|
||||
"music-videos": "Music Videos",
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from enum import Enum
|
||||
|
||||
from .constants import (
|
||||
ARTIST_AUTO_SELECT_KEY_MAP,
|
||||
ARTIST_AUTO_SELECT_STR_MAP,
|
||||
FOURCC_MAP,
|
||||
LEGACY_SONG_CODECS,
|
||||
MEDIA_RATING_STR_MAP,
|
||||
@@ -87,3 +89,26 @@ class MusicVideoResolution(Enum):
|
||||
class UploadedVideoQuality(Enum):
|
||||
BEST = "best"
|
||||
ASK = "ask"
|
||||
|
||||
|
||||
class CoverFormat(Enum):
|
||||
JPG = "jpg"
|
||||
PNG = "png"
|
||||
RAW = "raw"
|
||||
|
||||
|
||||
class ArtistMediaType(Enum):
|
||||
MAIN_ALBUMS = "main-albums"
|
||||
COMPILATION_ALBUMS = "compilation-albums"
|
||||
LIVE_ALBUMS = "live-albums"
|
||||
SINGLES_EPS = "singles-eps"
|
||||
ALL_ALBUMS = "all-albums"
|
||||
TOP_SONGS = "top-songs"
|
||||
MUSIC_VIDEOS = "music-videos"
|
||||
|
||||
@property
|
||||
def path_key(self) -> tuple[str, str]:
|
||||
return ARTIST_AUTO_SELECT_KEY_MAP[self.value]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return ARTIST_AUTO_SELECT_STR_MAP[self.value]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
from ..utils import GamdlError
|
||||
from typing import Any
|
||||
|
||||
|
||||
class GamdlInterfaceError(GamdlError):
|
||||
pass
|
||||
|
||||
|
||||
class GamdlInterfaceMediaNotStreamableError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Media is not streamable: {media_id}")
|
||||
|
||||
|
||||
class GamdlInterfaceFormatNotAvailableError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str, codec: Any | None = None):
|
||||
super().__init__(
|
||||
f"Requested format is not available (media ID: {media_id}): {codec}"
|
||||
)
|
||||
|
||||
|
||||
class GamdlInterfaceDecryptionNotAvailableError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Decryption is not available for media ID: {media_id}")
|
||||
|
||||
|
||||
class GamdlInterfaceMediaNotAllowedError(GamdlInterfaceError):
|
||||
def __init__(self, media_type: str, media_id: str | None = None):
|
||||
message = "Media type is disallowed"
|
||||
if media_id:
|
||||
message += f" (media ID: {media_id})"
|
||||
|
||||
super().__init__(f"{message}: {media_type}")
|
||||
|
||||
|
||||
class GamdlInterfaceUrlParseError(GamdlInterfaceError):
|
||||
def __init__(self, url: str):
|
||||
super().__init__(f"URL is not valid or supported: {url}")
|
||||
|
||||
|
||||
class GamdlInterfaceArtistMediaTypeError(GamdlInterfaceError):
|
||||
def __init__(self, media_type: str):
|
||||
super().__init__(f"Artist has no media of type: {media_type}")
|
||||
+592
-49
@@ -1,65 +1,608 @@
|
||||
import base64
|
||||
import datetime
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Any, AsyncGenerator, Callable
|
||||
|
||||
from pywidevine import PSSH, Cdm
|
||||
import structlog
|
||||
|
||||
from ..api.apple_music_api import AppleMusicApi
|
||||
from ..api.itunes_api import ItunesApi
|
||||
from .types import DecryptionKey
|
||||
from ..utils import safe_gather
|
||||
from .constants import VALID_URL_PATTERN
|
||||
from .enums import ArtistMediaType
|
||||
from .exceptions import (
|
||||
GamdlInterfaceMediaNotAllowedError,
|
||||
GamdlInterfaceUrlParseError,
|
||||
GamdlInterfaceArtistMediaTypeError,
|
||||
)
|
||||
from .music_video import AppleMusicMusicVideoInterface
|
||||
from .song import AppleMusicSongInterface
|
||||
from .types import AppleMusicMedia, AppleMusicUrlInfo
|
||||
from .uploaded_video import AppleMusicUploadedVideoInterface
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicInterface:
|
||||
def __init__(
|
||||
self,
|
||||
apple_music_api: AppleMusicApi,
|
||||
itunes_api: ItunesApi,
|
||||
song: AppleMusicSongInterface,
|
||||
music_video: AppleMusicMusicVideoInterface,
|
||||
uploaded_video: AppleMusicUploadedVideoInterface,
|
||||
artist_select_media_type_function: (
|
||||
Callable[[list[ArtistMediaType], dict], ArtistMediaType | None] | None
|
||||
) = None,
|
||||
artist_select_items_function: (
|
||||
Callable[[ArtistMediaType, list[dict]], list[dict] | None] | None
|
||||
) = None,
|
||||
flat_filter_function: Callable[[dict], Any] | None = None,
|
||||
concurrency: int = 5,
|
||||
disallowed_media_types: list[str] | None = None,
|
||||
) -> None:
|
||||
self.apple_music_api = apple_music_api
|
||||
self.itunes_api = itunes_api
|
||||
self.song = song
|
||||
self.music_video = music_video
|
||||
self.uploaded_video = uploaded_video
|
||||
self.artist_select_media_type_function = artist_select_media_type_function
|
||||
self.artist_select_items_function = artist_select_items_function
|
||||
self.flat_filter_function = flat_filter_function
|
||||
self.concurrency = concurrency
|
||||
self.disallowed_media_types = disallowed_media_types
|
||||
|
||||
self.base = song.base
|
||||
|
||||
@staticmethod
|
||||
def get_media_id_of_library_media(library_media_metadata: dict) -> str:
|
||||
play_params = library_media_metadata["attributes"].get("playParams", {})
|
||||
return play_params.get("catalogId", library_media_metadata["id"])
|
||||
def get_url_info(url: str) -> AppleMusicUrlInfo | None:
|
||||
log = logger.bind(action="get_url_info", url=url)
|
||||
|
||||
@staticmethod
|
||||
def parse_date(date: str) -> datetime.datetime:
|
||||
return datetime.datetime.fromisoformat(date.split("Z")[0])
|
||||
match = VALID_URL_PATTERN.match(url)
|
||||
if not match:
|
||||
log.debug("invalid_url_pattern")
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
track_uri: str,
|
||||
track_id: str,
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKey:
|
||||
try:
|
||||
cdm_session = cdm.open()
|
||||
return None
|
||||
|
||||
pssh_obj = PSSH(track_uri.split(",")[-1])
|
||||
|
||||
challenge = base64.b64encode(
|
||||
cdm.get_license_challenge(cdm_session, pssh_obj)
|
||||
).decode()
|
||||
license = await self.apple_music_api.get_license_exchange(
|
||||
track_id,
|
||||
track_uri,
|
||||
challenge,
|
||||
)
|
||||
|
||||
cdm.parse_license(cdm_session, license["license"])
|
||||
decryption_key_info = next(
|
||||
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
)
|
||||
finally:
|
||||
cdm.close(cdm_session)
|
||||
|
||||
decryption_key = DecryptionKey(
|
||||
key=decryption_key_info.key.hex(),
|
||||
kid=decryption_key_info.kid.hex,
|
||||
url_match = AppleMusicUrlInfo(
|
||||
**match.groupdict(),
|
||||
)
|
||||
logger.debug(f"Decryption key: {decryption_key}")
|
||||
|
||||
return decryption_key
|
||||
log.debug("success", url_info=url_match)
|
||||
|
||||
return url_match
|
||||
|
||||
async def _get_song_media(
|
||||
self,
|
||||
media_id: str | None = None,
|
||||
media_metadata: dict | None = None,
|
||||
playlist_metadata: dict | None = None,
|
||||
playlist_track: int | None = None,
|
||||
) -> AppleMusicMedia:
|
||||
if not media_metadata:
|
||||
try:
|
||||
media_metadata = (
|
||||
await self.base.apple_music_api.get_song(
|
||||
media_id,
|
||||
)
|
||||
)[
|
||||
"data"
|
||||
][0]
|
||||
except Exception as e:
|
||||
return AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=None,
|
||||
error=e,
|
||||
)
|
||||
|
||||
if not media_id:
|
||||
media_id = self.base.parse_catalog_media_id(media_metadata)
|
||||
|
||||
base_media = AppleMusicMedia(media_id, media_metadata)
|
||||
|
||||
if self.flat_filter_function:
|
||||
flat_filter_result = self.flat_filter_function(media_metadata)
|
||||
|
||||
if asyncio.iscoroutine(flat_filter_result):
|
||||
flat_filter_result = await flat_filter_result
|
||||
|
||||
if flat_filter_result:
|
||||
base_media.flat_filter_result = flat_filter_result
|
||||
return base_media
|
||||
|
||||
if (
|
||||
self.disallowed_media_types
|
||||
and base_media.media_metadata["type"] in self.disallowed_media_types
|
||||
):
|
||||
base_media.error = GamdlInterfaceMediaNotAllowedError(
|
||||
base_media.media_metadata["type"],
|
||||
media_id,
|
||||
)
|
||||
return base_media
|
||||
|
||||
try:
|
||||
return await self.song.get_media(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
playlist_track,
|
||||
)
|
||||
except Exception as e:
|
||||
base_media.error = e
|
||||
return base_media
|
||||
|
||||
async def _get_music_video_media(
|
||||
self,
|
||||
media_id: str | None = None,
|
||||
media_metadata: dict | None = None,
|
||||
playlist_metadata: dict | None = None,
|
||||
playlist_track: int | None = None,
|
||||
) -> AppleMusicMedia:
|
||||
if not media_metadata:
|
||||
try:
|
||||
media_metadata = (
|
||||
await self.base.apple_music_api.get_music_video(
|
||||
media_id,
|
||||
)
|
||||
)["data"][0]
|
||||
except Exception as e:
|
||||
return AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=None,
|
||||
error=e,
|
||||
)
|
||||
|
||||
if not media_id:
|
||||
media_id = self.music_video.parse_catalog_media_id(media_metadata)
|
||||
|
||||
base_media = AppleMusicMedia(media_id, media_metadata)
|
||||
|
||||
if self.flat_filter_function:
|
||||
flat_filter_result = self.flat_filter_function(media_metadata)
|
||||
|
||||
if asyncio.iscoroutine(flat_filter_result):
|
||||
flat_filter_result = await flat_filter_result
|
||||
|
||||
if flat_filter_result:
|
||||
base_media.flat_filter_result = flat_filter_result
|
||||
return base_media
|
||||
|
||||
if (
|
||||
self.disallowed_media_types
|
||||
and base_media.media_metadata["type"] in self.disallowed_media_types
|
||||
):
|
||||
base_media.error = GamdlInterfaceMediaNotAllowedError(
|
||||
base_media.media_metadata["type"],
|
||||
media_id,
|
||||
)
|
||||
return base_media
|
||||
|
||||
try:
|
||||
return await self.music_video.get_media(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
playlist_track,
|
||||
)
|
||||
except Exception as e:
|
||||
base_media.error = e
|
||||
return base_media
|
||||
|
||||
async def _get_uploaded_video_media(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> AppleMusicMedia:
|
||||
try:
|
||||
media_metadata = (
|
||||
await self.base.apple_music_api.get_uploaded_video(
|
||||
media_id,
|
||||
)
|
||||
)["data"][0]
|
||||
except Exception as e:
|
||||
return AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=None,
|
||||
error=e,
|
||||
)
|
||||
|
||||
base_media = AppleMusicMedia(media_id, media_metadata)
|
||||
|
||||
if self.flat_filter_function:
|
||||
flat_filter_result = self.flat_filter_function(media_metadata)
|
||||
|
||||
if asyncio.iscoroutine(flat_filter_result):
|
||||
flat_filter_result = await flat_filter_result
|
||||
|
||||
if flat_filter_result:
|
||||
base_media.flat_filter_result = flat_filter_result
|
||||
return base_media
|
||||
|
||||
if (
|
||||
self.disallowed_media_types
|
||||
and base_media.media_metadata["type"] in self.disallowed_media_types
|
||||
):
|
||||
base_media.error = GamdlInterfaceMediaNotAllowedError(
|
||||
base_media.media_metadata["type"],
|
||||
media_id,
|
||||
)
|
||||
return base_media
|
||||
|
||||
try:
|
||||
return await self.uploaded_video.get_media(media_metadata)
|
||||
except Exception as e:
|
||||
base_media.error = e
|
||||
return base_media
|
||||
|
||||
async def _get_album_media(
|
||||
self,
|
||||
media_id: str,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
try:
|
||||
media_metadata = (
|
||||
await self.base.apple_music_api.get_library_album(
|
||||
media_id,
|
||||
)
|
||||
if is_library
|
||||
else await self.base.apple_music_api.get_album(
|
||||
media_id,
|
||||
)
|
||||
)["data"][0]
|
||||
except Exception as e:
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=None,
|
||||
error=e,
|
||||
)
|
||||
return
|
||||
|
||||
if self.flat_filter_function:
|
||||
flat_filter_result = self.flat_filter_function(media_metadata)
|
||||
|
||||
if asyncio.iscoroutine(flat_filter_result):
|
||||
flat_filter_result = await flat_filter_result
|
||||
|
||||
if flat_filter_result:
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=media_metadata,
|
||||
flat_filter_result=flat_filter_result,
|
||||
)
|
||||
return
|
||||
|
||||
if (
|
||||
self.disallowed_media_types
|
||||
and media_metadata["type"] in self.disallowed_media_types
|
||||
):
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=media_metadata,
|
||||
error=GamdlInterfaceMediaNotAllowedError(
|
||||
media_metadata["type"],
|
||||
media_id,
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
tracks = media_metadata["relationships"]["tracks"]["data"]
|
||||
tasks = [
|
||||
(
|
||||
self._get_song_media(
|
||||
media_id=track["id"],
|
||||
media_metadata=track,
|
||||
playlist_metadata=media_metadata,
|
||||
)
|
||||
if track["type"] in {"songs", "library-songs"}
|
||||
else self._get_music_video_media(
|
||||
media_id=track["id"],
|
||||
media_metadata=track,
|
||||
playlist_metadata=media_metadata,
|
||||
)
|
||||
)
|
||||
for track in tracks
|
||||
]
|
||||
|
||||
if self.concurrency == 1:
|
||||
for task in tasks:
|
||||
async for result in task:
|
||||
yield result
|
||||
|
||||
else:
|
||||
for task in await safe_gather(*tasks, limit=self.concurrency):
|
||||
yield task
|
||||
|
||||
async def _get_playlist_media(
|
||||
self,
|
||||
media_id: str,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
try:
|
||||
media_metadata = (
|
||||
await self.base.apple_music_api.get_library_playlist(
|
||||
media_id,
|
||||
)
|
||||
if is_library
|
||||
else await self.base.apple_music_api.get_playlist(
|
||||
media_id,
|
||||
)
|
||||
)["data"][0]
|
||||
except Exception as e:
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=None,
|
||||
error=e,
|
||||
)
|
||||
return
|
||||
|
||||
if self.flat_filter_function:
|
||||
flat_filter_result = self.flat_filter_function(media_metadata)
|
||||
|
||||
if asyncio.iscoroutine(flat_filter_result):
|
||||
flat_filter_result = await flat_filter_result
|
||||
|
||||
if flat_filter_result:
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=media_metadata,
|
||||
flat_filter_result=flat_filter_result,
|
||||
)
|
||||
return
|
||||
|
||||
if (
|
||||
self.disallowed_media_types
|
||||
and media_metadata["type"] in self.disallowed_media_types
|
||||
):
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=media_metadata,
|
||||
error=GamdlInterfaceMediaNotAllowedError(
|
||||
media_metadata["type"],
|
||||
media_id,
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
tracks = media_metadata["relationships"]["tracks"]["data"]
|
||||
next_uri = media_metadata["relationships"]["tracks"].get("next")
|
||||
href_uri = media_metadata["relationships"]["tracks"].get("href")
|
||||
while next_uri:
|
||||
try:
|
||||
extended_data = await self.base.apple_music_api.get_extended_api_data(
|
||||
next_uri,
|
||||
href_uri,
|
||||
)
|
||||
except Exception as e:
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=media_metadata,
|
||||
error=e,
|
||||
)
|
||||
return
|
||||
tracks.extend(extended_data["data"])
|
||||
next_uri = extended_data.get("next")
|
||||
|
||||
tasks = [
|
||||
(
|
||||
self._get_song_media(
|
||||
media_id=track["id"],
|
||||
media_metadata=track,
|
||||
playlist_metadata=media_metadata,
|
||||
playlist_track=index + 1,
|
||||
)
|
||||
if track["type"] in {"songs", "library-songs"}
|
||||
else self._get_music_video_media(
|
||||
media_id=track["id"],
|
||||
media_metadata=track,
|
||||
playlist_metadata=media_metadata,
|
||||
playlist_track=index + 1,
|
||||
)
|
||||
)
|
||||
for index, track in enumerate(tracks)
|
||||
]
|
||||
|
||||
if self.concurrency == 1:
|
||||
for task in tasks:
|
||||
async for result in task:
|
||||
yield result
|
||||
|
||||
else:
|
||||
for task in await safe_gather(*tasks, limit=self.concurrency):
|
||||
yield task
|
||||
|
||||
async def _get_artist_media(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
try:
|
||||
media_metadata = (
|
||||
await self.base.apple_music_api.get_artist(
|
||||
media_id,
|
||||
)
|
||||
)[
|
||||
"data"
|
||||
][0]
|
||||
except Exception as e:
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=None,
|
||||
error=e,
|
||||
)
|
||||
return
|
||||
|
||||
if self.flat_filter_function:
|
||||
flat_filter_result = self.flat_filter_function(media_metadata)
|
||||
|
||||
if asyncio.iscoroutine(flat_filter_result):
|
||||
flat_filter_result = await flat_filter_result
|
||||
|
||||
if flat_filter_result:
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=media_metadata,
|
||||
flat_filter_result=flat_filter_result,
|
||||
)
|
||||
return
|
||||
|
||||
if (
|
||||
self.disallowed_media_types
|
||||
and media_metadata["type"] in self.disallowed_media_types
|
||||
):
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=media_metadata,
|
||||
error=GamdlInterfaceMediaNotAllowedError(
|
||||
media_metadata["type"],
|
||||
media_id,
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
if self.artist_select_media_type_function:
|
||||
artist_media_type = self.artist_select_media_type_function(
|
||||
list(ArtistMediaType),
|
||||
media_metadata,
|
||||
)
|
||||
if asyncio.iscoroutine(artist_media_type):
|
||||
artist_media_type = await artist_media_type
|
||||
else:
|
||||
artist_media_type = list(ArtistMediaType)[0]
|
||||
|
||||
relation_key, type_key = artist_media_type.path_key
|
||||
|
||||
items_relation = media_metadata.get(relation_key, {}).get(type_key, {})
|
||||
items = items_relation.get("data", [])
|
||||
if not items:
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=media_metadata,
|
||||
error=GamdlInterfaceArtistMediaTypeError(str(artist_media_type)),
|
||||
)
|
||||
return
|
||||
|
||||
next_uri = items_relation.get("next")
|
||||
href_uri = items_relation.get("href")
|
||||
while next_uri:
|
||||
try:
|
||||
extended_data = await self.base.apple_music_api.get_extended_api_data(
|
||||
next_uri,
|
||||
href_uri,
|
||||
)
|
||||
except Exception as e:
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=media_metadata,
|
||||
error=e,
|
||||
)
|
||||
return
|
||||
items.extend(extended_data.get("data", []))
|
||||
next_uri = extended_data.get("next")
|
||||
|
||||
if self.artist_select_items_function:
|
||||
selected_items = self.artist_select_items_function(
|
||||
artist_media_type,
|
||||
items,
|
||||
)
|
||||
if asyncio.iscoroutine(selected_items):
|
||||
selected_items = await selected_items
|
||||
else:
|
||||
selected_items = items[:1]
|
||||
|
||||
tasks = []
|
||||
for item in selected_items:
|
||||
if item["type"] in {"songs", "library-songs"}:
|
||||
tasks.append(
|
||||
(
|
||||
item["type"],
|
||||
self._get_song_media(
|
||||
media_id=item["id"],
|
||||
media_metadata=item,
|
||||
),
|
||||
)
|
||||
)
|
||||
elif item["type"] in {"albums", "library-albums"}:
|
||||
tasks.append(
|
||||
(
|
||||
item["type"],
|
||||
self._get_album_media(
|
||||
media_id=item["id"],
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
tasks.append(
|
||||
(
|
||||
item["type"],
|
||||
self._get_music_video_media(
|
||||
media_id=item["id"],
|
||||
media_metadata=item,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if self.concurrency == 1:
|
||||
for item_type, task in tasks:
|
||||
if item_type in {"albums", "library-albums"}:
|
||||
async for result in task:
|
||||
yield result
|
||||
else:
|
||||
yield await task
|
||||
|
||||
else:
|
||||
|
||||
async def _collect_generator(generator_or_coroutine, item_type):
|
||||
if item_type in {"albums", "library-albums"}:
|
||||
results = []
|
||||
async for result in generator_or_coroutine:
|
||||
results.append(result)
|
||||
return results
|
||||
else:
|
||||
return [await generator_or_coroutine]
|
||||
|
||||
collected_tasks = [
|
||||
_collect_generator(task, item_type) for item_type, task in tasks
|
||||
]
|
||||
for batch in await safe_gather(*collected_tasks, limit=self.concurrency):
|
||||
for media in batch:
|
||||
yield media
|
||||
|
||||
async def get_media_from_url(
|
||||
self,
|
||||
url: str,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
url_info = self.get_url_info(url)
|
||||
|
||||
if not url_info:
|
||||
raise GamdlInterfaceUrlParseError(url)
|
||||
|
||||
if self.disallowed_media_types and url_info.type in self.disallowed_media_types:
|
||||
raise GamdlInterfaceMediaNotAllowedError(
|
||||
url_info.type,
|
||||
)
|
||||
|
||||
if url_info.type == "song" or url_info.sub_id:
|
||||
media = await self._get_song_media(
|
||||
media_id=url_info.sub_id or url_info.id,
|
||||
)
|
||||
yield media
|
||||
|
||||
elif url_info.type == "music-video":
|
||||
media = await self._get_music_video_media(
|
||||
media_id=url_info.id,
|
||||
)
|
||||
yield media
|
||||
|
||||
elif url_info.type == "album" or url_info.library_type == "albums":
|
||||
async for media in self._get_album_media(
|
||||
media_id=url_info.library_id or url_info.id,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "playlist" or url_info.library_type == "playlist":
|
||||
async for media in self._get_playlist_media(
|
||||
media_id=url_info.library_id or url_info.id,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "post":
|
||||
media = await self._get_uploaded_video_media(
|
||||
media_id=url_info.id,
|
||||
)
|
||||
yield media
|
||||
|
||||
elif url_info.type == "artist":
|
||||
async for media in self._get_artist_media(
|
||||
media_id=url_info.id,
|
||||
):
|
||||
yield media
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
import logging
|
||||
import urllib.parse
|
||||
|
||||
import m3u8
|
||||
from async_lru import alru_cache
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
from pywidevine import Cdm
|
||||
|
||||
from ..utils import get_response_text
|
||||
from .constants import MP4_FORMAT_CODECS
|
||||
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
|
||||
from .interface import AppleMusicInterface
|
||||
from .types import DecryptionKeyAv, MediaFileFormat, MediaTags, StreamInfo, StreamInfoAv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleMusicMusicVideoInterface:
|
||||
def __init__(
|
||||
self,
|
||||
interface: AppleMusicInterface,
|
||||
):
|
||||
self.interface = interface
|
||||
|
||||
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(
|
||||
"music-video",
|
||||
alt_id,
|
||||
)
|
||||
return itunes_page["storePlatformData"]["product-dv"]["results"][alt_id]
|
||||
|
||||
def get_m3u8_master_url_from_webplayback(self, webplayback: dict) -> str:
|
||||
m3u8_master_url = webplayback["hls-playlist-url"]
|
||||
return m3u8_master_url
|
||||
|
||||
def get_m3u8_master_url_from_itunes_page_metadata(
|
||||
self,
|
||||
itunes_page_metadata: dict,
|
||||
) -> dict:
|
||||
stream_url = itunes_page_metadata["offers"][0]["assets"][0]["hlsUrl"]
|
||||
|
||||
url_parts = urllib.parse.urlparse(stream_url)
|
||||
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
|
||||
query.update({"aec": "HD", "dsid": "1"})
|
||||
|
||||
m3u8_master_url = url_parts._replace(
|
||||
query=urllib.parse.urlencode(query, doseq=True)
|
||||
).geturl()
|
||||
|
||||
return m3u8_master_url
|
||||
|
||||
def get_alt_id(self, metadata: dict) -> str | None:
|
||||
music_video_url = metadata["attributes"].get("url")
|
||||
if music_video_url is None:
|
||||
return None
|
||||
|
||||
alt_id = music_video_url.split("/")[-1].split("?")[0]
|
||||
logger.debug(f"Alt ID: {alt_id}")
|
||||
|
||||
return alt_id
|
||||
|
||||
@alru_cache()
|
||||
async def get_album(
|
||||
self,
|
||||
collection_id: int,
|
||||
) -> dict | None:
|
||||
album_response = await self.interface.apple_music_api.get_album(collection_id)
|
||||
if not album_response:
|
||||
return None
|
||||
return album_response["data"][0]
|
||||
|
||||
async def get_tags(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
) -> MediaTags:
|
||||
alt_id = self.get_alt_id(metadata)
|
||||
lookup_metadata = (await self.interface.itunes_api.get_lookup_result(alt_id))[
|
||||
"results"
|
||||
]
|
||||
|
||||
explicitness = lookup_metadata[0]["trackExplicitness"]
|
||||
if explicitness == "notExplicit":
|
||||
rating = MediaRating.NONE
|
||||
elif explicitness == "explicit":
|
||||
rating = MediaRating.EXPLICIT
|
||||
else:
|
||||
rating = MediaRating.CLEAN
|
||||
|
||||
tags = MediaTags(
|
||||
artist=lookup_metadata[0]["artistName"],
|
||||
artist_id=int(lookup_metadata[0]["artistId"]),
|
||||
copyright=itunes_page_metadata.get("copyright"),
|
||||
date=self.interface.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]),
|
||||
title=lookup_metadata[0]["trackCensoredName"],
|
||||
title_id=int(metadata["id"]),
|
||||
rating=rating,
|
||||
)
|
||||
|
||||
if len(lookup_metadata) > 1:
|
||||
album = await self.get_album(itunes_page_metadata["collectionId"])
|
||||
if not album:
|
||||
return tags
|
||||
|
||||
tags.album = lookup_metadata[1]["collectionCensoredName"]
|
||||
tags.album_artist = lookup_metadata[1]["artistName"]
|
||||
tags.album_id = int(itunes_page_metadata["collectionId"])
|
||||
tags.disc = lookup_metadata[0]["discNumber"]
|
||||
tags.disc_total = lookup_metadata[0]["discCount"]
|
||||
tags.compilation = album["attributes"]["isCompilation"]
|
||||
tags.track = lookup_metadata[0]["trackNumber"]
|
||||
tags.track_total = lookup_metadata[0]["trackCount"]
|
||||
|
||||
logger.debug(f"Tags: {tags}")
|
||||
|
||||
return tags
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
codec_priority: list[MusicVideoCodec],
|
||||
resolution: MusicVideoResolution,
|
||||
) -> StreamInfoAv:
|
||||
alt_video_id = self.get_alt_id(metadata)
|
||||
if alt_video_id == metadata["id"]:
|
||||
m3u8_master_url = self.get_m3u8_master_url_from_itunes_page_metadata(
|
||||
itunes_page_metadata,
|
||||
)
|
||||
else:
|
||||
webplayback_response = await self.interface.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.base_uri = m3u8_master_url.rpartition("/")[0]
|
||||
stream_info_video = await self.get_stream_info_video(
|
||||
playlist_master_m3u8_obj,
|
||||
codec_priority,
|
||||
resolution,
|
||||
)
|
||||
stream_info_audio = await self.get_stream_info_audio(
|
||||
playlist_master_m3u8_obj.data,
|
||||
codec_priority,
|
||||
)
|
||||
if not stream_info_video or not stream_info_audio:
|
||||
return None
|
||||
|
||||
use_mp4 = any(
|
||||
stream_info_video.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
) or any(
|
||||
stream_info_audio.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
)
|
||||
if use_mp4:
|
||||
file_format = MediaFileFormat.MP4
|
||||
else:
|
||||
file_format = MediaFileFormat.M4V
|
||||
|
||||
stream_info = StreamInfoAv(
|
||||
video_track=stream_info_video,
|
||||
audio_track=stream_info_audio,
|
||||
file_format=file_format,
|
||||
)
|
||||
logger.debug(f"Stream info: {stream_info}")
|
||||
|
||||
return stream_info
|
||||
|
||||
def get_video_playlist_from_resolution(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
codec: 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:
|
||||
return None
|
||||
|
||||
def sort_key(playlist: m3u8.Playlist) -> tuple[int, int, int, int]:
|
||||
playlist_resolution = playlist.stream_info.resolution[-1]
|
||||
resolution_difference = abs(playlist_resolution - int(resolution))
|
||||
bandwidth = playlist.stream_info.bandwidth
|
||||
|
||||
return (
|
||||
resolution_difference,
|
||||
-playlist_resolution,
|
||||
-bandwidth,
|
||||
)
|
||||
|
||||
playlists_filtered.sort(key=sort_key)
|
||||
|
||||
return playlists_filtered[0]
|
||||
|
||||
def get_best_stereo_audio_playlist(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict | None:
|
||||
audio_playlist = next(
|
||||
(
|
||||
media
|
||||
for media in playlist_master_data["media"]
|
||||
if media["group_id"] == "audio-stereo-256"
|
||||
),
|
||||
None,
|
||||
)
|
||||
return audio_playlist
|
||||
|
||||
async def get_video_playlist_from_user(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
) -> m3u8.Playlist:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
playlist.stream_info.codecs[:4],
|
||||
"x".join(str(v) for v in playlist.stream_info.resolution),
|
||||
str(playlist.stream_info.bandwidth),
|
||||
]
|
||||
),
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in video_playlists
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
async def get_audio_playlist_from_user(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["group_id"],
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlist_master_data["media"]
|
||||
if playlist.get("uri")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which audio codec to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
def get_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return next(
|
||||
(
|
||||
key
|
||||
for key in m3u8_obj.keys
|
||||
if key.keyformat == "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
|
||||
),
|
||||
None,
|
||||
).uri
|
||||
|
||||
async def get_stream_info_video(
|
||||
self,
|
||||
playlist_master_m3u8_obj: m3u8.M3U8,
|
||||
codec_priority: list[MusicVideoCodec],
|
||||
resolution: MusicVideoResolution,
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in codec_priority:
|
||||
for codec in codec_priority:
|
||||
playlist = self.get_video_playlist_from_resolution(
|
||||
playlist_master_m3u8_obj.playlists,
|
||||
codec,
|
||||
resolution,
|
||||
)
|
||||
if playlist:
|
||||
break
|
||||
else:
|
||||
playlist = await self.get_video_playlist_from_user(
|
||||
playlist_master_m3u8_obj.playlists
|
||||
)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist.uri
|
||||
stream_info.codec = playlist.stream_info.codecs
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
|
||||
stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def get_stream_info_audio(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
codec_priority: list[MusicVideoCodec],
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in codec_priority:
|
||||
playlist = self.get_best_stereo_audio_playlist(playlist_master_data)
|
||||
else:
|
||||
playlist = await self.get_audio_playlist_from_user(playlist_master_data)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["group_id"]
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
|
||||
stream_info.widevine_pssh = self.get_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKeyAv:
|
||||
decryption_key_video = await self.interface.get_decryption_key(
|
||||
stream_info.video_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
cdm,
|
||||
)
|
||||
decryption_key_audio = await self.interface.get_decryption_key(
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
cdm,
|
||||
)
|
||||
|
||||
return DecryptionKeyAv(
|
||||
video_track=decryption_key_video,
|
||||
audio_track=decryption_key_audio,
|
||||
)
|
||||
@@ -1,86 +0,0 @@
|
||||
import logging
|
||||
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from ..interface.enums import UploadedVideoQuality
|
||||
from ..interface.types import MediaTags
|
||||
from .constants import UPLOADED_VIDEO_QUALITY_RANK
|
||||
from .interface import AppleMusicInterface
|
||||
from .types import StreamInfo, StreamInfoAv, MediaFileFormat
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoInterface:
|
||||
def __init__(self, interface: AppleMusicInterface):
|
||||
self.interface = interface
|
||||
|
||||
def get_stream_url_best(self, metadata: dict) -> str:
|
||||
best_quality = next(
|
||||
(
|
||||
quality
|
||||
for quality in UPLOADED_VIDEO_QUALITY_RANK
|
||||
if metadata["attributes"]["assetTokens"].get(quality)
|
||||
),
|
||||
None,
|
||||
)
|
||||
return metadata["attributes"]["assetTokens"][best_quality]
|
||||
|
||||
async def get_stream_url_from_user(self, metadata: dict) -> str:
|
||||
qualities = list(metadata["attributes"]["assetTokens"].keys())
|
||||
choices = [
|
||||
Choice(
|
||||
name=quality,
|
||||
value=quality,
|
||||
)
|
||||
for quality in qualities
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which quality to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return metadata["attributes"]["assetTokens"][selected]
|
||||
|
||||
async def get_stream_url(
|
||||
self, metadata: dict, quality: UploadedVideoQuality
|
||||
) -> str:
|
||||
if quality == UploadedVideoQuality.BEST:
|
||||
stream_url = self.get_stream_url_best(metadata)
|
||||
|
||||
if quality == UploadedVideoQuality.ASK:
|
||||
stream_url = await self.get_stream_url_from_user(metadata)
|
||||
|
||||
logger.debug(f"Stream URL: {stream_url}")
|
||||
|
||||
return stream_url
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
metadata: dict,
|
||||
quality: UploadedVideoQuality,
|
||||
) -> StreamInfo:
|
||||
stream_url = await self.get_stream_url(metadata, quality)
|
||||
stream_info = StreamInfoAv(
|
||||
file_format=MediaFileFormat.M4V,
|
||||
video_track=StreamInfo(
|
||||
stream_url=stream_url,
|
||||
),
|
||||
)
|
||||
return stream_info
|
||||
|
||||
def get_tags(self, metadata: dict) -> MediaTags:
|
||||
attributes = metadata["attributes"]
|
||||
upload_date = attributes.get("uploadDate")
|
||||
|
||||
tags = MediaTags(
|
||||
artist=attributes.get("artistName"),
|
||||
date=self.interface.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]),
|
||||
)
|
||||
logger.debug(f"Tags: {tags}")
|
||||
|
||||
return tags
|
||||
@@ -0,0 +1,426 @@
|
||||
import asyncio
|
||||
import urllib.parse
|
||||
from typing import Callable
|
||||
|
||||
import m3u8
|
||||
import structlog
|
||||
|
||||
from .base import AppleMusicBaseInterface
|
||||
from .constants import MP4_FORMAT_CODECS
|
||||
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
|
||||
from .exceptions import (
|
||||
GamdlInterfaceDecryptionNotAvailableError,
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
)
|
||||
from .types import (
|
||||
AppleMusicMedia,
|
||||
DecryptionKeyAv,
|
||||
MediaFileFormat,
|
||||
MediaTags,
|
||||
StreamInfo,
|
||||
StreamInfoAv,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicMusicVideoInterface:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseInterface,
|
||||
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
|
||||
codec_priority: list[MusicVideoCodec] = [
|
||||
MusicVideoCodec.H264,
|
||||
MusicVideoCodec.H265,
|
||||
],
|
||||
ask_video_codec_function: (
|
||||
Callable[[list[m3u8.Playlist]], dict | None] | None
|
||||
) = None,
|
||||
ask_audio_codec_function: Callable[[list[dict]], dict | None] | None = None,
|
||||
):
|
||||
self.base = base
|
||||
self.resolution = resolution
|
||||
self.codec_priority = codec_priority
|
||||
self.ask_video_codec_function = ask_video_codec_function
|
||||
self.ask_audio_codec_function = ask_audio_codec_function
|
||||
|
||||
async def get_itunes_page_metadata(
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
) -> dict:
|
||||
url_media_id = self.base.parse_media_id_from_url(music_video_metadata)
|
||||
itunes_page = await self.base.itunes_api.get_itunes_page(
|
||||
"music-video",
|
||||
url_media_id,
|
||||
)
|
||||
return itunes_page["storePlatformData"]["product-dv"]["results"][url_media_id]
|
||||
|
||||
def _get_m3u8_master_url_from_webplayback(self, webplayback: dict) -> str:
|
||||
m3u8_master_url = webplayback["hls-playlist-url"]
|
||||
return m3u8_master_url
|
||||
|
||||
def _get_m3u8_master_url_from_itunes_page_metadata(
|
||||
self,
|
||||
itunes_page_metadata: dict,
|
||||
) -> str | None:
|
||||
stream_url = itunes_page_metadata["offers"][0]["assets"][0].get("hlsUrl")
|
||||
if not stream_url:
|
||||
return None
|
||||
|
||||
url_parts = urllib.parse.urlparse(stream_url)
|
||||
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
|
||||
query.update({"aec": "HD", "dsid": "1"})
|
||||
|
||||
m3u8_master_url = url_parts._replace(
|
||||
query=urllib.parse.urlencode(query, doseq=True)
|
||||
).geturl()
|
||||
|
||||
return m3u8_master_url
|
||||
|
||||
async def get_tags(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
) -> MediaTags:
|
||||
log = logger.bind(
|
||||
action="get_music_video_tags",
|
||||
media_id=self.base.parse_catalog_media_id(metadata),
|
||||
)
|
||||
|
||||
url_media_id = self.base.parse_media_id_from_url(metadata)
|
||||
lookup_metadata = (await self.base.itunes_api.get_lookup_result(url_media_id))[
|
||||
"results"
|
||||
]
|
||||
|
||||
explicitness = lookup_metadata[0]["trackExplicitness"]
|
||||
if explicitness == "notExplicit":
|
||||
rating = MediaRating.NONE
|
||||
elif explicitness == "explicit":
|
||||
rating = MediaRating.EXPLICIT
|
||||
else:
|
||||
rating = MediaRating.CLEAN
|
||||
|
||||
tags = MediaTags(
|
||||
artist=lookup_metadata[0]["artistName"],
|
||||
artist_id=int(lookup_metadata[0]["artistId"]),
|
||||
copyright=itunes_page_metadata.get("copyright"),
|
||||
date=self.base.parse_date(lookup_metadata[0]["releaseDate"]),
|
||||
genre=lookup_metadata[0]["primaryGenreName"],
|
||||
genre_id=int(itunes_page_metadata["genres"][0]["genreId"]),
|
||||
media_type=MediaType.MUSIC_VIDEO,
|
||||
storefront=self.base.itunes_api.storefront_id,
|
||||
title=lookup_metadata[0]["trackCensoredName"],
|
||||
title_id=int(metadata["id"]),
|
||||
rating=rating,
|
||||
)
|
||||
|
||||
if len(lookup_metadata) > 1:
|
||||
album = await self.base.get_album_cached(
|
||||
itunes_page_metadata["collectionId"]
|
||||
)
|
||||
if not album:
|
||||
return tags
|
||||
|
||||
tags.album = lookup_metadata[1]["collectionCensoredName"]
|
||||
tags.album_artist = lookup_metadata[1]["artistName"]
|
||||
tags.album_id = int(itunes_page_metadata["collectionId"])
|
||||
tags.disc = lookup_metadata[0]["discNumber"]
|
||||
tags.disc_total = lookup_metadata[0]["discCount"]
|
||||
tags.compilation = album["attributes"]["isCompilation"]
|
||||
tags.track = lookup_metadata[0]["trackNumber"]
|
||||
tags.track_total = lookup_metadata[0]["trackCount"]
|
||||
|
||||
log.debug("success", tags=tags)
|
||||
|
||||
return tags
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
) -> StreamInfoAv | None:
|
||||
log = logger.bind(
|
||||
action="get_music_video_stream_info",
|
||||
media_id=self.base.parse_catalog_media_id(metadata),
|
||||
)
|
||||
|
||||
url_media_id = self.base.parse_media_id_from_url(metadata)
|
||||
m3u8_master_url = None
|
||||
|
||||
if url_media_id == metadata["id"]:
|
||||
m3u8_master_url = self._get_m3u8_master_url_from_itunes_page_metadata(
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
if not m3u8_master_url:
|
||||
webplayback_response = await self.base.apple_music_api.get_webplayback(
|
||||
metadata["id"]
|
||||
)
|
||||
m3u8_master_url = self._get_m3u8_master_url_from_webplayback(
|
||||
webplayback_response["songList"][0],
|
||||
)
|
||||
|
||||
playlist_master_m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(m3u8_master_url)).text
|
||||
)
|
||||
playlist_master_m3u8_obj.base_uri = m3u8_master_url.rpartition("/")[0]
|
||||
stream_info_video = await self._get_stream_info_video(playlist_master_m3u8_obj)
|
||||
stream_info_audio = await self._get_stream_info_audio(
|
||||
playlist_master_m3u8_obj.data,
|
||||
)
|
||||
if not stream_info_video or not stream_info_audio:
|
||||
return None
|
||||
|
||||
use_mp4 = any(
|
||||
stream_info_video.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
) or any(
|
||||
stream_info_audio.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
)
|
||||
if use_mp4:
|
||||
file_format = MediaFileFormat.MP4
|
||||
else:
|
||||
file_format = MediaFileFormat.M4V
|
||||
|
||||
stream_info = StreamInfoAv(
|
||||
video_track=stream_info_video,
|
||||
audio_track=stream_info_audio,
|
||||
file_format=file_format,
|
||||
)
|
||||
|
||||
log.debug("success", stream_info=stream_info)
|
||||
|
||||
return stream_info
|
||||
|
||||
def _get_video_playlist_from_resolution(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
) -> m3u8.Playlist | None:
|
||||
playlist_results = []
|
||||
for codec_index, codec in enumerate(self.codec_priority):
|
||||
for playlist in video_playlists:
|
||||
if playlist.stream_info.codecs.startswith(codec.fourcc()):
|
||||
playlist_results.append((codec_index, playlist))
|
||||
|
||||
if not playlist_results:
|
||||
return None
|
||||
|
||||
def sort_key(
|
||||
item: tuple[int, m3u8.Playlist],
|
||||
) -> tuple[bool, int, int, int, int]:
|
||||
codec_index, playlist = item
|
||||
playlist_resolution = playlist.stream_info.resolution[-1]
|
||||
bandwidth = playlist.stream_info.bandwidth
|
||||
exceeds_resolution = playlist_resolution > int(self.resolution)
|
||||
resolution_difference = abs(playlist_resolution - int(self.resolution))
|
||||
|
||||
return (
|
||||
exceeds_resolution,
|
||||
resolution_difference,
|
||||
codec_index,
|
||||
-playlist_resolution,
|
||||
-bandwidth,
|
||||
)
|
||||
|
||||
playlist_results.sort(key=sort_key)
|
||||
return playlist_results[0][1]
|
||||
|
||||
def _get_best_stereo_audio_playlist(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict | None:
|
||||
audio_playlist = next(
|
||||
(
|
||||
media
|
||||
for media in playlist_master_data["media"]
|
||||
if media["group_id"] == "audio-stereo-256"
|
||||
),
|
||||
None,
|
||||
)
|
||||
return audio_playlist
|
||||
|
||||
async def _get_video_playlist_from_user(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
) -> m3u8.Playlist | None:
|
||||
if self.ask_video_codec_function:
|
||||
video_playlist = self.ask_video_codec_function(video_playlists)
|
||||
if asyncio.iscoroutine(video_playlist):
|
||||
video_playlist = await video_playlist
|
||||
|
||||
return video_playlist
|
||||
|
||||
return None
|
||||
|
||||
async def _get_audio_playlist_from_user(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict | None:
|
||||
if self.ask_audio_codec_function:
|
||||
audio_playlist = self.ask_audio_codec_function(
|
||||
[
|
||||
playlist
|
||||
for playlist in playlist_master_data["media"]
|
||||
if playlist.get("uri")
|
||||
]
|
||||
)
|
||||
if asyncio.iscoroutine(audio_playlist):
|
||||
audio_playlist = await audio_playlist
|
||||
|
||||
return audio_playlist
|
||||
|
||||
return None
|
||||
|
||||
def _get_key_by_format(
|
||||
self,
|
||||
m3u8_obj: m3u8.M3U8,
|
||||
key_format: str,
|
||||
) -> str:
|
||||
return next(
|
||||
(key for key in m3u8_obj.keys if key.keyformat == key_format),
|
||||
None,
|
||||
).uri
|
||||
|
||||
def _get_widevine_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
|
||||
)
|
||||
|
||||
def _get_playready_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.microsoft.playready",
|
||||
)
|
||||
|
||||
def _get_fairplay_key(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
|
||||
async def _get_stream_info_video(
|
||||
self,
|
||||
playlist_master_m3u8_obj: m3u8.M3U8,
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in self.codec_priority:
|
||||
playlist = self._get_video_playlist_from_resolution(
|
||||
playlist_master_m3u8_obj.playlists,
|
||||
)
|
||||
else:
|
||||
playlist = await self._get_video_playlist_from_user(
|
||||
playlist_master_m3u8_obj.playlists
|
||||
)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist.uri
|
||||
stream_info.codec = playlist.stream_info.codecs
|
||||
stream_info.width, stream_info.height = playlist.stream_info.resolution
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self._get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self._get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self._get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def _get_stream_info_audio(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in self.codec_priority:
|
||||
playlist = self._get_best_stereo_audio_playlist(playlist_master_data)
|
||||
else:
|
||||
playlist = await self._get_audio_playlist_from_user(playlist_master_data)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["group_id"]
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self._get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self._get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self._get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
) -> DecryptionKeyAv:
|
||||
decryption_key_video, decryption_key_audio = await asyncio.gather(
|
||||
self.base.get_decryption_key(
|
||||
stream_info.video_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
),
|
||||
self.base.get_decryption_key(
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
),
|
||||
)
|
||||
|
||||
return DecryptionKeyAv(
|
||||
video_track=decryption_key_video,
|
||||
audio_track=decryption_key_audio,
|
||||
)
|
||||
|
||||
async def get_media(
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
playlist_metadata: dict | None = None,
|
||||
playlist_track: dict | None = None,
|
||||
) -> AppleMusicMedia:
|
||||
media = AppleMusicMedia(
|
||||
media_id=self.base.parse_catalog_media_id(music_video_metadata),
|
||||
media_metadata=music_video_metadata,
|
||||
)
|
||||
|
||||
if not self.base.is_media_streamable(music_video_metadata):
|
||||
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
|
||||
|
||||
if playlist_metadata and playlist_track:
|
||||
media.playlist_metadata = playlist_metadata
|
||||
media.playlist_tags = self.base.get_playlist_tags(
|
||||
playlist_metadata,
|
||||
playlist_track,
|
||||
)
|
||||
|
||||
media.cover = await self.base.get_cover(music_video_metadata)
|
||||
|
||||
itunes_page_metadata = await self.get_itunes_page_metadata(music_video_metadata)
|
||||
media.tags = await self.get_tags(
|
||||
music_video_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
media.stream_info = await self.get_stream_info(
|
||||
music_video_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
if not media.stream_info:
|
||||
raise GamdlInterfaceFormatNotAvailableError(
|
||||
media.media_id,
|
||||
self.codec_priority,
|
||||
)
|
||||
|
||||
if (
|
||||
not media.stream_info.video_track.widevine_pssh
|
||||
or not media.stream_info.audio_track.widevine_pssh
|
||||
):
|
||||
raise GamdlInterfaceDecryptionNotAvailableError(media.media_id)
|
||||
|
||||
media.decryption_key = await self.get_decryption_key(media.stream_info)
|
||||
|
||||
return media
|
||||
@@ -1,23 +1,25 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Callable
|
||||
from xml.dom import minidom
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import m3u8
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
from pywidevine import PSSH, Cdm
|
||||
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||
import structlog
|
||||
|
||||
from ..utils import get_response_text
|
||||
from .base import AppleMusicBaseInterface
|
||||
from .constants import DRM_DEFAULT_KEY_MAPPING, MP4_FORMAT_CODECS, SONG_CODEC_REGEX_MAP
|
||||
from .enums import MediaRating, MediaType, SongCodec, SyncedLyricsFormat
|
||||
from .interface import AppleMusicInterface
|
||||
from .exceptions import (
|
||||
GamdlInterfaceDecryptionNotAvailableError,
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
)
|
||||
from .types import (
|
||||
DecryptionKey,
|
||||
AppleMusicMedia,
|
||||
DecryptionKeyAv,
|
||||
Lyrics,
|
||||
MediaFileFormat,
|
||||
@@ -26,22 +28,39 @@ from .types import (
|
||||
StreamInfoAv,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicSongInterface:
|
||||
def __init__(
|
||||
self,
|
||||
interface: AppleMusicInterface,
|
||||
) -> None:
|
||||
self.interface = interface
|
||||
base: AppleMusicBaseInterface,
|
||||
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
|
||||
codec_priority: list[SongCodec] = [SongCodec.AAC_LEGACY],
|
||||
use_album_date: bool = False,
|
||||
skip_decryption_key_non_legacy: bool = False,
|
||||
skip_stream_info: bool = False,
|
||||
ask_codec_function: Callable[[list[dict]], dict | None] | None = None,
|
||||
):
|
||||
self.base = base
|
||||
self.synced_lyrics_format = synced_lyrics_format
|
||||
self.codec_priority = codec_priority
|
||||
self.use_album_date = use_album_date
|
||||
self.skip_decryption_key_non_legacy = skip_decryption_key_non_legacy
|
||||
self.skip_stream_info = skip_stream_info
|
||||
self.ask_codec_function = ask_codec_function
|
||||
|
||||
async def get_lyrics(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
synced_lyrics_format: SyncedLyricsFormat,
|
||||
) -> Lyrics | None:
|
||||
log = logger.bind(
|
||||
action="get_lyrics",
|
||||
song_id=self.base.parse_catalog_media_id(song_metadata),
|
||||
)
|
||||
|
||||
if not song_metadata["attributes"]["hasLyrics"]:
|
||||
log.debug("no_lyrics")
|
||||
return None
|
||||
|
||||
if (
|
||||
@@ -49,8 +68,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.base.apple_music_api.get_song(
|
||||
self.base.parse_catalog_media_id(song_metadata)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
@@ -68,16 +87,17 @@ class AppleMusicSongInterface:
|
||||
song_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
|
||||
"ttml"
|
||||
],
|
||||
synced_lyrics_format,
|
||||
)
|
||||
logging.debug(f"Lyrics: {lyrics}")
|
||||
|
||||
log.debug("success", lyrics=lyrics)
|
||||
|
||||
return lyrics
|
||||
else:
|
||||
log.debug("no_lyrics_data")
|
||||
|
||||
def _get_lyrics(
|
||||
self,
|
||||
lyrics_ttml: str,
|
||||
synced_lyrics_format: SyncedLyricsFormat,
|
||||
) -> Lyrics:
|
||||
lyrics_ttml_et = ElementTree.fromstring(lyrics_ttml)
|
||||
unsynced_lyrics = []
|
||||
@@ -93,13 +113,13 @@ class AppleMusicSongInterface:
|
||||
stanza.append(p.text)
|
||||
|
||||
if p.attrib.get("begin"):
|
||||
if synced_lyrics_format == SyncedLyricsFormat.LRC:
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.LRC:
|
||||
synced_lyrics.append(self._get_lyrics_line_lrc(p))
|
||||
|
||||
if synced_lyrics_format == SyncedLyricsFormat.SRT:
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.SRT:
|
||||
synced_lyrics.append(self._get_lyrics_line_srt(index, p))
|
||||
|
||||
if synced_lyrics_format == SyncedLyricsFormat.TTML:
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.TTML:
|
||||
if not synced_lyrics:
|
||||
synced_lyrics.append(
|
||||
minidom.parseString(lyrics_ttml).toprettyxml()
|
||||
@@ -109,9 +129,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,11 +190,13 @@ 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,
|
||||
) -> MediaTags:
|
||||
log = logger.bind(action="get_song_tags")
|
||||
|
||||
webplayback_metadata = webplayback["songList"][0]["assets"][0]["metadata"]
|
||||
|
||||
tags = MediaTags(
|
||||
@@ -194,9 +218,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.base.get_media_date(webplayback_metadata["playlistId"])
|
||||
if self.use_album_date
|
||||
else (
|
||||
self.base.parse_date(webplayback_metadata["releaseDate"])
|
||||
if webplayback_metadata.get("releaseDate")
|
||||
else None
|
||||
)
|
||||
),
|
||||
disc=webplayback_metadata["discNumber"],
|
||||
disc_total=webplayback_metadata["discCount"],
|
||||
@@ -214,22 +242,45 @@ class AppleMusicSongInterface:
|
||||
track_total=webplayback_metadata["trackCount"],
|
||||
xid=webplayback_metadata.get("xid"),
|
||||
)
|
||||
logger.debug(f"Tags: {tags}")
|
||||
|
||||
log.debug("success", tags=tags)
|
||||
|
||||
return tags
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
song_metadata: dict | None = None,
|
||||
webplayback: dict | None = None,
|
||||
) -> StreamInfoAv | None:
|
||||
for codec in self.codec_priority:
|
||||
if codec.is_legacy():
|
||||
return await self._get_stream_info_legacy(webplayback, codec)
|
||||
else:
|
||||
return await self._get_stream_info(song_metadata, codec)
|
||||
|
||||
async def _get_stream_info(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
codec: SongCodec,
|
||||
) -> StreamInfoAv | None:
|
||||
log = logger.bind(action="get_song_stream_info")
|
||||
|
||||
if "extendedAssetUrls" not in song_metadata["attributes"]:
|
||||
song_metadata = (
|
||||
await self.base.apple_music_api.get_song(
|
||||
self.base.parse_catalog_media_id(song_metadata),
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
m3u8_master_url = song_metadata["attributes"]["extendedAssetUrls"].get(
|
||||
"enhancedHls"
|
||||
)
|
||||
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 self.base.get_response(m3u8_master_url)).text
|
||||
)
|
||||
m3u8_master_data = m3u8_master_obj.data
|
||||
|
||||
if codec == SongCodec.ASK:
|
||||
@@ -241,9 +292,10 @@ class AppleMusicSongInterface:
|
||||
)
|
||||
|
||||
if playlist is None:
|
||||
log.debug("no_matching_playlist", codec=codec.value)
|
||||
return None
|
||||
|
||||
stream_info = StreamInfo()
|
||||
stream_info = StreamInfo(legacy=False)
|
||||
stream_info.stream_url = (
|
||||
f"{m3u8_master_url.rpartition('/')[0]}/{playlist['uri']}"
|
||||
)
|
||||
@@ -273,7 +325,9 @@ class AppleMusicSongInterface:
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
else:
|
||||
m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
|
||||
m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(stream_info.stream_url)).text
|
||||
)
|
||||
|
||||
stream_info.widevine_pssh = self._get_drm_uri_from_m3u8_keys(
|
||||
m3u8_obj,
|
||||
@@ -292,7 +346,8 @@ class AppleMusicSongInterface:
|
||||
audio_track=stream_info,
|
||||
file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A,
|
||||
)
|
||||
logger.debug(f"Stream info: {stream_info_av}")
|
||||
|
||||
log.debug("success", stream_info=stream_info_av)
|
||||
|
||||
return stream_info_av
|
||||
|
||||
@@ -336,18 +391,16 @@ class AppleMusicSongInterface:
|
||||
)
|
||||
|
||||
async def _get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["stream_info"]["audio"],
|
||||
value=playlist,
|
||||
if self.ask_codec_function:
|
||||
playlist = self.ask_codec_function(
|
||||
[playlist for playlist in m3u8_data["playlists"]]
|
||||
)
|
||||
for playlist in m3u8_data["playlists"]
|
||||
]
|
||||
if asyncio.iscoroutine(playlist):
|
||||
playlist = await playlist
|
||||
|
||||
return await inquirer.select(
|
||||
message="Select which codec to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
return playlist
|
||||
|
||||
return None
|
||||
|
||||
def _get_drm_uri_from_session_key(
|
||||
self,
|
||||
@@ -372,19 +425,23 @@ class AppleMusicSongInterface:
|
||||
return key.uri
|
||||
return None
|
||||
|
||||
async def get_stream_info_legacy(
|
||||
async def _get_stream_info_legacy(
|
||||
self,
|
||||
webplayback: dict,
|
||||
codec: SongCodec,
|
||||
) -> StreamInfoAv:
|
||||
log = logger.bind(action="get_legacy_song_stream_info")
|
||||
|
||||
flavor = "32:ctrp64" if codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
|
||||
|
||||
stream_info = StreamInfo()
|
||||
stream_info = StreamInfo(legacy=True)
|
||||
stream_info.stream_url = next(
|
||||
i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor
|
||||
)["URL"]
|
||||
|
||||
m3u8_obj = m3u8.loads(await get_response_text(stream_info.stream_url))
|
||||
m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
|
||||
|
||||
stream_info_av = StreamInfoAv(
|
||||
@@ -392,65 +449,73 @@ class AppleMusicSongInterface:
|
||||
audio_track=stream_info,
|
||||
file_format=MediaFileFormat.M4A,
|
||||
)
|
||||
logger.debug(f"Stream info legacy: {stream_info_av}")
|
||||
log.debug("success", stream_info=stream_info_av)
|
||||
|
||||
return stream_info_av
|
||||
|
||||
async def get_decryption_key_legacy(
|
||||
async def get_media(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKeyAv:
|
||||
stream_info_audio = stream_info.audio_track
|
||||
song_metadata: dict,
|
||||
playlist_metadata: dict | None = None,
|
||||
playlist_track: int | None = None,
|
||||
) -> AppleMusicMedia:
|
||||
media = AppleMusicMedia(
|
||||
media_id=self.base.parse_catalog_media_id(song_metadata),
|
||||
media_metadata=song_metadata,
|
||||
)
|
||||
|
||||
try:
|
||||
cdm_session = cdm.open()
|
||||
|
||||
widevine_pssh_data = WidevinePsshData()
|
||||
widevine_pssh_data.algorithm = 1
|
||||
widevine_pssh_data.key_ids.append(
|
||||
base64.b64decode(stream_info_audio.widevine_pssh.split(",")[1])
|
||||
if not self.base.is_media_streamable(song_metadata):
|
||||
raise GamdlInterfaceMediaNotStreamableError(
|
||||
media_id=media.media_id,
|
||||
)
|
||||
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,
|
||||
if playlist_metadata and playlist_track:
|
||||
media.playlist_metadata = playlist_metadata
|
||||
media.playlist_tags = self.base.get_playlist_tags(
|
||||
playlist_metadata,
|
||||
playlist_track,
|
||||
)
|
||||
|
||||
media.cover = await self.base.get_cover(song_metadata)
|
||||
|
||||
media.lyrics = await self.get_lyrics(song_metadata)
|
||||
|
||||
webplayback = await self.base.apple_music_api.get_webplayback(media.media_id)
|
||||
|
||||
media.tags = await self.get_tags(
|
||||
webplayback,
|
||||
media.lyrics.unsynced if media.lyrics else None,
|
||||
)
|
||||
|
||||
if not self.skip_stream_info:
|
||||
media.stream_info = await self.get_stream_info(
|
||||
song_metadata,
|
||||
webplayback,
|
||||
)
|
||||
if not media.stream_info:
|
||||
raise GamdlInterfaceFormatNotAvailableError(
|
||||
media_id=media.media_id,
|
||||
codec=self.codec_priority,
|
||||
)
|
||||
)
|
||||
|
||||
cdm.parse_license(cdm_session, license_response["license"])
|
||||
if (
|
||||
not self.skip_decryption_key_non_legacy
|
||||
and not media.stream_info.audio_track.widevine_pssh
|
||||
) or (
|
||||
self.skip_decryption_key_non_legacy
|
||||
and not media.stream_info.audio_track.fairplay_key
|
||||
):
|
||||
raise GamdlInterfaceDecryptionNotAvailableError(media_id=media.media_id)
|
||||
|
||||
decryption_key = next(
|
||||
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
)
|
||||
finally:
|
||||
cdm.close(cdm_session)
|
||||
if (
|
||||
media.stream_info.audio_track.widevine_pssh
|
||||
and not self.skip_decryption_key_non_legacy
|
||||
) or media.stream_info.audio_track.legacy:
|
||||
media.decryption_key = DecryptionKeyAv(
|
||||
audio_track=await self.base.get_decryption_key(
|
||||
media.stream_info.audio_track.widevine_pssh,
|
||||
media.media_id,
|
||||
)
|
||||
)
|
||||
|
||||
decryption_key = DecryptionKeyAv(
|
||||
audio_track=DecryptionKey(
|
||||
kid=decryption_key.kid.hex,
|
||||
key=decryption_key.key.hex(),
|
||||
)
|
||||
)
|
||||
logger.debug(f"Decryption key legacy: {decryption_key}")
|
||||
|
||||
return decryption_key
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKeyAv:
|
||||
return DecryptionKeyAv(
|
||||
audio_track=await self.interface.get_decryption_key(
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
cdm,
|
||||
)
|
||||
)
|
||||
return media
|
||||
+81
-41
@@ -1,5 +1,6 @@
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .enums import MediaFileFormat, MediaRating, MediaType
|
||||
|
||||
@@ -44,22 +45,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,43 +69,48 @@ 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
|
||||
class PlaylistTags:
|
||||
playlist_artist: str = None
|
||||
artist: str = None
|
||||
playlist_id: int = None
|
||||
playlist_title: str = None
|
||||
playlist_track: int = None
|
||||
title: str = None
|
||||
track: int = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -118,6 +120,9 @@ class StreamInfo:
|
||||
playready_pssh: str = None
|
||||
fairplay_key: str = None
|
||||
codec: str = None
|
||||
width: int = None
|
||||
height: int = None
|
||||
legacy: bool = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -138,3 +143,38 @@ class DecryptionKey:
|
||||
class DecryptionKeyAv:
|
||||
video_track: DecryptionKey = None
|
||||
audio_track: DecryptionKey = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cover:
|
||||
template_url: str = None
|
||||
file_extension: str = None
|
||||
url: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppleMusicMedia:
|
||||
media_id: str
|
||||
media_metadata: dict
|
||||
error: BaseException | None = None
|
||||
playlist_metadata: dict | None = None
|
||||
playlist_tags: PlaylistTags | None = None
|
||||
extra_tags: dict | None = None
|
||||
cover: Cover | None = None
|
||||
lyrics: Lyrics | None = None
|
||||
tags: MediaTags | None = None
|
||||
stream_info: StreamInfoAv | None = None
|
||||
decryption_key: DecryptionKeyAv | None = None
|
||||
flat_filter_result: Any = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppleMusicUrlInfo:
|
||||
storefront: str = None
|
||||
type: str = None
|
||||
slug: str = None
|
||||
id: str = None
|
||||
sub_id: str = None
|
||||
library_storefront: str = None
|
||||
library_type: str = None
|
||||
library_id: str = None
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
|
||||
import structlog
|
||||
|
||||
from .base import AppleMusicBaseInterface
|
||||
from .constants import UPLOADED_VIDEO_QUALITY_RANK
|
||||
from .enums import UploadedVideoQuality
|
||||
from .exceptions import (
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
)
|
||||
from .types import AppleMusicMedia, MediaFileFormat, MediaTags, StreamInfo, StreamInfoAv
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoInterface:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseInterface,
|
||||
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
|
||||
ask_quality_function: Callable[[dict], dict | None] | None = None,
|
||||
):
|
||||
self.base = base
|
||||
self.quality = quality
|
||||
self.ask_quality_function = ask_quality_function
|
||||
|
||||
def _get_best_stream_url(self, metadata: dict) -> str:
|
||||
best_quality = next(
|
||||
(
|
||||
quality
|
||||
for quality in UPLOADED_VIDEO_QUALITY_RANK
|
||||
if metadata["attributes"]["assetTokens"].get(quality)
|
||||
),
|
||||
None,
|
||||
)
|
||||
return metadata["attributes"]["assetTokens"][best_quality]
|
||||
|
||||
async def _get_stream_url_from_user(self, metadata: dict) -> str | None:
|
||||
if self.ask_quality_function:
|
||||
selected_quality = self.ask_quality_function(
|
||||
metadata["attributes"]["assetTokens"]
|
||||
)
|
||||
if asyncio.iscoroutine(selected_quality):
|
||||
selected_quality = await selected_quality
|
||||
return selected_quality
|
||||
|
||||
return None
|
||||
|
||||
async def _get_stream_url(
|
||||
self,
|
||||
metadata: dict,
|
||||
) -> str | None:
|
||||
if self.quality == UploadedVideoQuality.BEST:
|
||||
stream_url = self._get_best_stream_url(metadata)
|
||||
|
||||
if self.quality == UploadedVideoQuality.ASK:
|
||||
stream_url = await self._get_stream_url_from_user(metadata)
|
||||
|
||||
return stream_url
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
metadata: dict,
|
||||
) -> StreamInfo | None:
|
||||
log = logger.bind(
|
||||
action="get_uploaded_video_stream_info", media_id=metadata["id"]
|
||||
)
|
||||
|
||||
stream_url = await self._get_stream_url(metadata)
|
||||
if not stream_url:
|
||||
log.debug("no_stream_url_available")
|
||||
|
||||
return None
|
||||
|
||||
stream_info = StreamInfoAv(
|
||||
file_format=MediaFileFormat.M4V,
|
||||
video_track=StreamInfo(
|
||||
stream_url=stream_url,
|
||||
),
|
||||
)
|
||||
|
||||
log.debug("success", stream_info=stream_info)
|
||||
|
||||
return stream_info
|
||||
|
||||
def get_tags(self, metadata: dict) -> MediaTags:
|
||||
log = logger.bind(action="get_uploaded_video_tags", media_id=metadata["id"])
|
||||
|
||||
attributes = metadata["attributes"]
|
||||
upload_date = attributes.get("uploadDate")
|
||||
|
||||
tags = MediaTags(
|
||||
artist=attributes.get("artistName"),
|
||||
date=self.base.parse_date(upload_date) if upload_date else None,
|
||||
title=attributes.get("name"),
|
||||
title_id=int(metadata["id"]),
|
||||
storefront=self.base.itunes_api.storefront_id,
|
||||
)
|
||||
|
||||
log.debug("success", tags=tags)
|
||||
|
||||
return tags
|
||||
|
||||
async def get_media(
|
||||
self,
|
||||
uploaded_video_metadata: dict,
|
||||
) -> AppleMusicMedia:
|
||||
media = AppleMusicMedia(
|
||||
uploaded_video_metadata["id"],
|
||||
uploaded_video_metadata,
|
||||
)
|
||||
|
||||
if not self.base.is_media_streamable(uploaded_video_metadata):
|
||||
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
|
||||
|
||||
media.cover = await self.base.get_cover(uploaded_video_metadata)
|
||||
|
||||
media.stream_info = await self.get_stream_info(uploaded_video_metadata)
|
||||
if not media.stream_info:
|
||||
raise GamdlInterfaceFormatNotAvailableError(media.media_id)
|
||||
|
||||
media.tags = self.get_tags(uploaded_video_metadata)
|
||||
|
||||
return media
|
||||
@@ -0,0 +1,3 @@
|
||||
# Dumped from Android Studio Virtual Device running Android 9
|
||||
|
||||
WVD = """V1ZEAgIDAASoMIIEpAIBAAKCAQEAwnCFAPXy4U1J7p1NohAS+xl040f5FBaE/59bPp301bGz0UGFT9VoEtY3vaeakKh/d319xTNvCSWsEDRaMmp/wSnMiEZUkkl04872jx2uHuR4k6KYuuJoqhsIo1TwUBueFZynHBUJzXQeW8Eb1tYAROGwp8W7r+b0RIjHC89RFnfVXpYlF5I6McktyzJNSOwlQbMqlVihfSUkv3WRd3HFmA0Oxay51CEIkoTlNTHVlzVyhov5eHCDSp7QENRgaaQ03jC/CcgFOoQymhsBtRCM0CQmfuAHjA9e77R6m/GJPy75G9fqoZM1RMzVDHKbKZPd3sFd0c0+77gLzW8cWEaaHwIDAQABAoIBAQCB2pN46MikHvHZIcTPDt0eRQoDH/YArGl2Lf7J+sOgU2U7wv49KtCug9IGHwDiyyUVsAFmycrF2RroV45FTUq0vi2SdSXV7Kjb20Ren/vBNeQw9M37QWmU8Sj7q6YyWb9hv5T69DHvvDTqIjVtbM4RMojAAxYti5hmjNIh2PrWfVYWhXxCQ/WqAjWLtZBM6Oww1byfr5I/wFogAKkgHi8wYXZ4LnIC8V7jLAhujlToOvMMC9qwcBiPKDP2FO+CPSXaqVhH+LPSEgLggnU3EirihgxovbLNAuDEeEbRTyR70B0lW19tLHixso4ZQa7KxlVUwOmrHSZf7nVuWqPpxd+BAoGBAPQLyJ1IeRavmaU8XXxfMdYDoc8+xB7v2WaxkGXb6ToX1IWPkbMz4yyVGdB5PciIP3rLZ6s1+ruuRRV0IZ98i1OuN5TSR56ShCGg3zkd5C4L/xSMAz+NDfYSDBdO8BVvBsw21KqSRUi1ctL7QiIvfedrtGb5XrE4zhH0gjXlU5qZAoGBAMv2segn0Jx6az4rqRa2Y7zRx4iZ77JUqYDBI8WMnFeR54uiioTQ+rOs3zK2fGIWlrn4ohco/STHQSUTB8oCOFLMx1BkOqiR+UyebO28DJY7+V9ZmxB2Guyi7W8VScJcIdpSOPyJFOWZQKXdQFW3YICD2/toUx/pDAJh1sEVQsV3AoGBANyyp1rthmvoo5cVbymhYQ08vaERDwU3PLCtFXu4E0Ow90VNn6Ki4ueXcv/gFOp7pISk2/yuVTBTGjCblCiJ1en4HFWekJwrvgg3Vodtq8Okn6pyMCHRqvWEPqD5hw6rGEensk0K+FMXnF6GULlfn4mgEkYpb+PvDhSYvQSGfkPJAoGAF/bAKFqlM/1eJEvU7go35bNwEiij9Pvlfm8y2L8Qj2lhHxLV240CJ6IkBz1Rl+S3iNohkT8LnwqaKNT3kVB5daEBufxMuAmOlOX4PmZdxDj/r6hDg8ecmjj6VJbXt7JDd/c5ItKoVeGPqu035dpJyE+1xPAY9CLZel4scTsiQTkCgYBt3buRcZMwnc4qqpOOQcXK+DWD6QvpkcJ55ygHYw97iP/lF4euwdHd+I5b+11pJBAao7G0fHX3eSjqOmzReSKboSe5L8ZLB2cAI8AsKTBfKHWmCa8kDtgQuI86fUfirCGdhdA9AVP2QXN2eNCuPnFWi0WHm4fYuUB5be2c18ucxAb9CAESmgsK3QMIAhIQ071yBlsbLoO2CSB9Ds0cmRif6uevBiKOAjCCAQoCggEBAMJwhQD18uFNSe6dTaIQEvsZdONH+RQWhP+fWz6d9NWxs9FBhU/VaBLWN72nmpCof3d9fcUzbwklrBA0WjJqf8EpzIhGVJJJdOPO9o8drh7keJOimLriaKobCKNU8FAbnhWcpxwVCc10HlvBG9bWAEThsKfFu6/m9ESIxwvPURZ31V6WJReSOjHJLcsyTUjsJUGzKpVYoX0lJL91kXdxxZgNDsWsudQhCJKE5TUx1Zc1coaL+Xhwg0qe0BDUYGmkNN4wvwnIBTqEMpobAbUQjNAkJn7gB4wPXu+0epvxiT8u+RvX6qGTNUTM1QxymymT3d7BXdHNPu+4C81vHFhGmh8CAwEAASjwIkgBUqoBCAEQABqBAQQlRbfiBNDb6eU6aKrsH5WJaYszTioXjPLrWN9dqyW0vwfT11kgF0BbCGkAXew2tLJJqIuD95cjJvyGUSN6VyhL6dp44fWEGDSBIPR0mvRq7bMP+m7Y/RLKf83+OyVJu/BpxivQGC5YDL9f1/A8eLhTDNKXs4Ia5DrmTWdPTPBL8SIgyfUtg3ofI+/I9Tf7it7xXpT0AbQBJfNkcNXGpO3JcBMSgAIL5xsXK5of1mMwAl6ygN1Gsj4aZ052otnwN7kXk12SMsXheWTZ/PYh2KRzmt9RPS1T8hyFx/Kp5VkBV2vTAqqWrGw/dh4URqiHATZJUlhO7PN5m2Kq1LVFdXjWSzP5XBF2S83UMe+YruNHpE5GQrSyZcBqHO0QrdPcU35GBT7S7+IJr2AAXvnjqnb8yrtpPWN2ZW/IWUJN2z4vZ7/HV4aj3OZhkxC1DIMNyvsusUKoQQuf8gwKiEe8cFwbwFSicywlFk9la2IPe8oFShcxAzHLCCn/TIYUAvEL3/4LgaZvqWm80qCPYbgIP5HT8hPYkKWJ4WYknEWK+3InbnkzteFfGrQFCq4CCAESEGnj6Ji7LD+4o7MoHYT4jBQYjtW+kQUijgIwggEKAoIBAQDY9um1ifBRIOmkPtDZTqH+CZUBbb0eK0Cn3NHFf8MFUDzPEz+emK/OTub/hNxCJCao//pP5L8tRNUPFDrrvCBMo7Rn+iUb+mA/2yXiJ6ivqcN9Cu9i5qOU1ygon9SWZRsujFFB8nxVreY5Lzeq0283zn1Cg1stcX4tOHT7utPzFG/ReDFQt0O/GLlzVwB0d1sn3SKMO4XLjhZdncrtF9jljpg7xjMIlnWJUqxDo7TQkTytJmUl0kcM7bndBLerAdJFGaXc6oSY4eNy/IGDluLCQR3KZEQsy/mLeV1ggQ44MFr7XOM+rd+4/314q/deQbjHqjWFuVr8iIaKbq+R63ShAgMBAAEo8CISgAMii2Mw6z+Qs1bvvxGStie9tpcgoO2uAt5Zvv0CDXvrFlwnSbo+qR71Ru2IlZWVSbN5XYSIDwcwBzHjY8rNr3fgsXtSJty425djNQtF5+J2jrAhf3Q2m7EI5aohZGpD2E0cr+dVj9o8x0uJR2NWR8FVoVQSXZpad3M/4QzBLNto/tz+UKyZwa7Sc/eTQc2+ZcDS3ZEO3lGRsH864Kf/cEGvJRBBqcpJXKfG+ItqEW1AAPptjuggzmZEzRq5xTGf6or+bXrKjCpBS9G1SOyvCNF1k5z6lG8KsXhgQxL6ADHMoulxvUIihyPY5MpimdXfUdEQ5HA2EqNiNVNIO4qP007jW51yAeThOry4J22xs8RdkIClOGAauLIl0lLA4flMzW+VfQl5xYxP0E5tuhn0h+844DslU8ZF7U1dU2QprIApffXD9wgAACk26Rggy8e96z8i86/+YYyZQkc9hIdCAERrgEYCEbByzONrdRDs1MrS/ch1moV5pJv63BIKvQHGvLkaFwoMY29tcGFueV9uYW1lEgd1bmtub3duGioKCm1vZGVsX25hbWUSHEFuZHJvaWQgU0RLIGJ1aWx0IGZvciB4ODZfNjQaGwoRYXJjaGl0ZWN0dXJlX25hbWUSBng4Nl82NBodCgtkZXZpY2VfbmFtZRIOZ2VuZXJpY194ODZfNjQaIAoMcHJvZHVjdF9uYW1lEhBzZGtfcGhvbmVfeDg2XzY0GmMKCmJ1aWxkX2luZm8SVUFuZHJvaWQvc2RrX3Bob25lX3g4Nl82NC9nZW5lcmljX3g4Nl82NDo5L1BTUjEuMTgwNzIwLjAxMi80OTIzMjE0OnVzZXJkZWJ1Zy90ZXN0LWtleXMaHgoUd2lkZXZpbmVfY2RtX3ZlcnNpb24SBjE0LjAuMBokCh9vZW1fY3J5cHRvX3NlY3VyaXR5X3BhdGNoX2xldmVsEgEwMg4QASAAKA0wAEAASABQAA=="""
|
||||
+24
-37
@@ -1,30 +1,7 @@
|
||||
import json
|
||||
import typing
|
||||
import subprocess
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def raise_for_status(httpx_response: httpx.Response, valid_responses: set[int] = {200}):
|
||||
if httpx_response.status_code not in valid_responses:
|
||||
raise httpx._exceptions.HTTPError(
|
||||
f"HTTP error {httpx_response.status_code}: {httpx_response.text}"
|
||||
)
|
||||
|
||||
|
||||
def safe_json(httpx_response: httpx.Response) -> dict:
|
||||
try:
|
||||
return httpx_response.json()
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
async def get_response_text(url: str) -> str:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
raise_for_status(response)
|
||||
return response.text
|
||||
import string
|
||||
import subprocess
|
||||
import typing
|
||||
|
||||
|
||||
async def async_subprocess(*args: str, silent: bool = False) -> None:
|
||||
@@ -48,24 +25,34 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class GamdlError(Exception):
|
||||
pass
|
||||
|
||||
+15
-2
@@ -1,21 +1,34 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
version = "2.7"
|
||||
version = "3.0"
|
||||
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",
|
||||
"httpx-retries>=0.4.6",
|
||||
"inquirerpy>=0.3.4",
|
||||
"m3u8>=6.0.0",
|
||||
"mutagen>=1.47.0",
|
||||
"pillow>=12.0.0",
|
||||
"pywidevine>=1.8.0",
|
||||
"structlog>=25.5.0",
|
||||
"yt-dlp>=2025.10.22",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/glomatico/gamdl"
|
||||
|
||||
[project.scripts]
|
||||
gamdl = "gamdl.cli.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
]
|
||||
|
||||
@@ -29,6 +29,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-asyncio-runner"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-datetime-fromisoformat"
|
||||
version = "2.0.3"
|
||||
@@ -188,6 +197,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,33 +223,53 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gamdl"
|
||||
version = "2.7"
|
||||
version = "3.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "async-lru" },
|
||||
{ name = "click" },
|
||||
{ name = "colorama" },
|
||||
{ name = "dataclass-click" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-retries" },
|
||||
{ name = "inquirerpy" },
|
||||
{ name = "m3u8" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pywidevine" },
|
||||
{ name = "structlog" },
|
||||
{ name = "yt-dlp" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "async-lru", specifier = ">=2.0.5" },
|
||||
{ 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 = "httpx-retries", specifier = ">=0.4.6" },
|
||||
{ name = "inquirerpy", specifier = ">=0.3.4" },
|
||||
{ name = "m3u8", specifier = ">=6.0.0" },
|
||||
{ name = "mutagen", specifier = ">=1.47.0" },
|
||||
{ name = "pillow", specifier = ">=12.0.0" },
|
||||
{ name = "pywidevine", specifier = ">=1.8.0" },
|
||||
{ name = "structlog", specifier = ">=25.5.0" },
|
||||
{ name = "yt-dlp", specifier = ">=2025.10.22" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -266,6 +307,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-retries"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/13/5eac2df576c02280f79e4639a6d4c93a25cfe94458275f5aa55f5e6c8ea0/httpx_retries-0.4.6.tar.gz", hash = "sha256:a076d8a5ede5d5794e9c241da17b15b393b482129ddd2fdf1fa56a3fa1f28a7f", size = 13466, upload-time = "2026-02-17T16:16:05.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/97/63f56da4400034adde22adfe7524635dba068f17d6858f92ecd96f55b53e/httpx_retries-0.4.6-py3-none-any.whl", hash = "sha256:d66d912173b844e065ffb109345a453b922f4c2cd9c9e11139304cb33e7a1ee1", size = 8490, upload-time = "2026-02-17T16:16:04.137Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@@ -275,6 +328,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inquirerpy"
|
||||
version = "0.3.4"
|
||||
@@ -309,6 +371,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pfzy"
|
||||
version = "0.3.4"
|
||||
@@ -416,6 +487,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.52"
|
||||
@@ -477,6 +557,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pymp4"
|
||||
version = "1.4.0"
|
||||
@@ -489,6 +578,38 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a2/27fea39af627c0ce5dbf6108bf969ea8f5fc9376d29f11282a80e3426f1d/pymp4-1.4.0-py3-none-any.whl", hash = "sha256:3401666c1e2a97ac94dffb18c5a5dcbd46d0a436da5272d378a6f9f6506dd12d", size = 14832, upload-time = "2023-05-07T15:01:32.293Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywidevine"
|
||||
version = "1.8.0"
|
||||
@@ -595,6 +716,72 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "structlog"
|
||||
version = "25.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
||||
Reference in New Issue
Block a user