mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 20:25:13 +03:00
Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73e0b4b48d | |||
| 8f82697c14 | |||
| 4650391be3 | |||
| 0519adf693 | |||
| 4fc91bac9f | |||
| cb367049f1 | |||
| 34357ad31e | |||
| a7140cb860 | |||
| aa14693924 | |||
| 76a7c792cd | |||
| c75249bc2d | |||
| 001a502a5c | |||
| 1eba432153 | |||
| 622661a679 | |||
| 8200ee0dd1 | |||
| a8bf884d8f | |||
| 6d8ecf65b6 | |||
| 03fb4a255e | |||
| f8ec2367af | |||
| b5432d1344 | |||
| bd59bb7c98 | |||
| 92b8220c71 | |||
| ccd51d4dc1 | |||
| 35b3013b87 | |||
| 8aeda0abff | |||
| 30aeee90b8 | |||
| 67bdfe8584 | |||
| 97086adfbe | |||
| da7346f704 | |||
| 3dd829b38c | |||
| c503d482a7 | |||
| 46df1672d9 | |||
| d61e315362 | |||
| b787e64820 | |||
| 31d6ba7c93 | |||
| 4841b953a7 | |||
| ada986573d | |||
| 8ea1373c83 | |||
| b7fdf7356f | |||
| fba6a72747 | |||
| 48df71271b | |||
| cbd161038e | |||
| 66c3a0fcf1 | |||
| b0b13e8367 | |||
| 7dab944908 | |||
| ffeb3bcfec | |||
| 6aae17c138 | |||
| 4cdad09372 | |||
| 86bbb94274 | |||
| e44b037414 | |||
| 2205b76c07 | |||
| 82e3cf20a0 | |||
| bc4cdd181c | |||
| dec4a22208 | |||
| b48dbeff8e | |||
| 34a397eb18 | |||
| 2c3abfd352 | |||
| 1fc708177c | |||
| f670fe8e95 | |||
| 8f184fcb66 | |||
| 3765ef0df4 | |||
| 4e28b7e9a3 | |||
| a009071a8d | |||
| 64b1974232 | |||
| 37ede6572e | |||
| 2e57216c3c | |||
| 5d242c89cd | |||
| e5675f8874 | |||
| 716112c294 | |||
| 63ad0f2e07 | |||
| 939520b3f8 | |||
| df23276d3c | |||
| a9227493ea | |||
| 9375c2fccd | |||
| c83e47df0c | |||
| 715820e357 | |||
| 137a739af2 | |||
| 23220d1827 | |||
| 3c7ea272af | |||
| 34a92b6efc | |||
| 3a907cb76c | |||
| 90646e7193 | |||
| 3b2875ccd1 | |||
| a989d9fefa | |||
| fd3b6216c9 | |||
| 84c21c0013 | |||
| aca3339b16 | |||
| 6d6f9f4441 | |||
| fe98bdb42c | |||
| 7c8b20d8f3 | |||
| 6232493eed | |||
| 09997bd6a1 | |||
| 54c318908c | |||
| dc6f2e8506 | |||
| eff41a40f5 | |||
| b00163a71c | |||
| 9f60043375 | |||
| 004ecd7c64 | |||
| 581bb7e094 | |||
| 5fd10d897e | |||
| 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 |
@@ -23,32 +23,57 @@ A command-line app for downloading Apple Music songs, music videos and post vide
|
||||
### Required
|
||||
|
||||
- **Python 3.10 or higher**
|
||||
- **Apple Music Cookies** - Export your browser cookies in Netscape format while logged in with an active subscription at the Apple Music website:
|
||||
- **Active Apple Music subscription**
|
||||
- **Apple Music Cookies** - export your browser cookies in Netscape format while logged in at [Apple Music](https://music.apple.com):
|
||||
- **Firefox**: [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt)
|
||||
- **Chromium**: [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
||||
|
||||
### Optional
|
||||
### Optional Dependencies
|
||||
|
||||
Add these tools to your system PATH for additional features:
|
||||
#### Wrapper
|
||||
|
||||
- **[FFmpeg](https://ffmpeg.org/download.html)** - Required for `ffmpeg` music video remux mode
|
||||
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required for `mp4box` music video remux mode
|
||||
- **[MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)** - Required for `mp4box` music video remux mode
|
||||
- **[N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)** - Required for `nm3u8dlre` download mode, which is faster than the default downloader
|
||||
- **[Wrapper](#️-wrapper)** - For downloading songs in ALAC and other experimental codecs without API limitations
|
||||
Run the [Wrapper v2](https://github.com/glomatico/wrapper-v2) server for wrapper-backed account, playback, and decryption requests. Enable it with `--use-wrapper` or `use_wrapper = true`, and configure the base URL with `--wrapper-url` or `wrapper_url`.
|
||||
|
||||
The wrapper is recommended when using these non-web song codecs:
|
||||
|
||||
- `aac`
|
||||
- `aac-he`
|
||||
- `aac-binaural`
|
||||
- `aac-downmix`
|
||||
- `aac-he-binaural`
|
||||
- `aac-he-downmix`
|
||||
- `atmos`
|
||||
- `ac3`
|
||||
- `alac`
|
||||
|
||||
**Note:**
|
||||
|
||||
- When using the Wrapper, you'll be asked to insert your credentials to login if you haven't already.
|
||||
- Web song codecs such as `aac-web` and `aac-he-web` do not require the wrapper.
|
||||
- Cookies can be skipped when using the wrapper.
|
||||
|
||||
#### N_m3u8DL-RE
|
||||
|
||||
Use [N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest) as a faster download alternative to the default yt-dlp download mode. Enable it with `--download-mode nm3u8dlre` or `download_mode = nm3u8dlre`.
|
||||
|
||||
If the executable is not available in your system PATH, set its location with `--nm3u8dlre-path` or `nm3u8dlre_path`.
|
||||
|
||||
N_m3u8DL-RE also needs FFmpeg. If the FFmpeg executable is not available in your system PATH, set its location with `--ffmpeg-path` or `ffmpeg_path`.
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
**Install Gamdl via pip:**
|
||||
1. **Install Gamdl via pip:**
|
||||
|
||||
```bash
|
||||
pip install gamdl
|
||||
```
|
||||
```bash
|
||||
pip install gamdl
|
||||
```
|
||||
|
||||
**Setup cookies:**
|
||||
2. **Set up the cookies file:**
|
||||
- Place the cookies file in the working directory as `cookies.txt`, or
|
||||
- Specify the path using `--cookies-path` or in the config file
|
||||
|
||||
1. Place your cookies file in the working directory as `cookies.txt`, or
|
||||
2. Specify the path using `--cookies-path` or in the config file
|
||||
3. **Optional: Set up dependencies** (only if you need the functionality)
|
||||
See the [Optional Dependencies](#optional-dependencies) section to determine which optional tools you need.
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
@@ -58,10 +83,10 @@ gamdl [OPTIONS] URLS...
|
||||
|
||||
### Supported URL Types
|
||||
|
||||
- Songs
|
||||
- Albums (Public/Library)
|
||||
- Playlists (Public/Library)
|
||||
- Music Videos
|
||||
- Songs (Catalog/Library)
|
||||
- Albums (Catalog/Library)
|
||||
- Playlists (Catalog/Library)
|
||||
- Music Videos (Catalog/Library)
|
||||
- Artists
|
||||
- Post Videos
|
||||
- Apple Music Classical
|
||||
@@ -108,63 +133,60 @@ The file is created automatically on first run. Command-line arguments override
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| ------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| **General Options** | | |
|
||||
| `--read-urls-as-txt`, `-r` | Read URLs from text files | `false` |
|
||||
| `--config-path` | Config file path | `<home>/.gamdl/config.ini` |
|
||||
| `--log-level` | Logging level | `INFO` |
|
||||
| `--log-file` | Log file path | - |
|
||||
| `--no-exceptions` | Don't print exceptions | `false` |
|
||||
| `--no-config-file`, `-n` | Don't use a config file | `false` |
|
||||
| **Apple Music Options** | | |
|
||||
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
|
||||
| `--wrapper-account-url` | Wrapper account URL | `http://127.0.0.1:30020` |
|
||||
| `--language`, `-l` | Metadata language | `en-US` |
|
||||
| **Output Options** | | |
|
||||
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
|
||||
| `--temp-path` | Temporary directory path | `.` |
|
||||
| `--wvd-path` | .wvd file path | - |
|
||||
| `--overwrite` | Overwrite existing files | `false` |
|
||||
| `--save-cover`, `-s` | Save cover as separate file | `false` |
|
||||
| `--save-playlist` | Save M3U8 playlist file | `false` |
|
||||
| **Download Options** | | |
|
||||
| `--artist-auto-select` | Automatically select artist content to download (artist URLs) | - |
|
||||
| `--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 | `false` |
|
||||
| `--wrapper-decrypt-ip` | Wrapper decryption server IP | `127.0.0.1:10020` |
|
||||
| `--download-mode` | Download mode | `ytdlp` |
|
||||
| `--cover-format` | Cover format | `jpg` |
|
||||
| **Template Options** | | |
|
||||
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
|
||||
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
|
||||
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
|
||||
| `--single-disc-file-template` | Single disc file template | `{track:02d} {title}` |
|
||||
| `--multi-disc-file-template` | Multi disc file template | `{disc}-{track:02d} {title}` |
|
||||
| `--no-album-file-template` | No album file template | `{title}` |
|
||||
| `--playlist-file-template` | Playlist file template | `Playlists/{playlist_artist}/{playlist_title}` |
|
||||
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` | Comma-separated tags to exclude | - |
|
||||
| `--cover-size` | Cover size in pixels | `1200` |
|
||||
| `--truncate` | Max filename length | - |
|
||||
| **Song Options** | | |
|
||||
| `--song-codec-priority` | Comma-separated codec priority | `aac-legacy` |
|
||||
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
|
||||
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
|
||||
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
|
||||
| `--use-album-date` | Use album release date for songs | `false` |
|
||||
| `--fetch-extra-tags` | Fetch extra tags from preview (normalization and smooth playback) | `false` |
|
||||
| **Music Video Options** | | |
|
||||
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
|
||||
| `--music-video-remux-mode` | Remux mode | `ffmpeg` |
|
||||
| `--music-video-remux-format` | Music video remux format | `m4v` |
|
||||
| `--music-video-resolution` | Max music video resolution | `1080p` |
|
||||
| **Post Video Options** | | |
|
||||
| `--uploaded-video-quality` | Post video quality | `best` |
|
||||
|
||||
| Option | Description | Default |
|
||||
| ------------------------------- | ----------------------------------------------------------------- | ----------------------------- |
|
||||
| **General Options** | | |
|
||||
| `--read-urls-as-txt`, `-r` | Read URLs from text files | `false` |
|
||||
| `--config-path` | Config file path | `<home>/.gamdl/config.ini` |
|
||||
| `--log-level` | Logging level | `INFO` |
|
||||
| `--log-file` | Log file path | - |
|
||||
| `--no-exceptions` | Don't print exceptions | `false` |
|
||||
| `--artist-auto-select` | Automatically select artist content to download (artist URLs) | - |
|
||||
| `--database-path` | Path to the SQLite database file for registering downloaded media | - |
|
||||
| `--no-config-file`, `-n` | Don't use a config file | `false` |
|
||||
| **Apple Music Options** | | |
|
||||
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
|
||||
| `--wrapper-url` | Wrapper base URL | `http://127.0.0.1` |
|
||||
| `--language`, `-l` | Metadata language | `en-US` |
|
||||
| **Interface Options** | | |
|
||||
| `--cover-format` | Cover format | `jpg` |
|
||||
| `--cover-size` | Cover size in pixels | `1200` |
|
||||
| `--wvd-path` | .wvd file path | - |
|
||||
| `--use-wrapper` | Use wrapper for account, playback, and decryption requests | `false` |
|
||||
| **Song Options** | | |
|
||||
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
|
||||
| `--song-codec-priority` | Comma-separated codec priority | `aac-web` |
|
||||
| `--use-album-date` | Use album release date for songs | `false` |
|
||||
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
|
||||
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
|
||||
| **Music Video Options** | | |
|
||||
| `--music-video-resolution` | Max music video resolution | `1080p` |
|
||||
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
|
||||
| `--music-video-remux-format` | Music video remux format | `m4v` |
|
||||
| **Post Video Options** | | |
|
||||
| `--uploaded-video-quality` | Post video quality | `best` |
|
||||
| **Download & Path Options** | | |
|
||||
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
|
||||
| `--temp-path` | Temporary directory path | `.` |
|
||||
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
|
||||
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
|
||||
| `--download-mode` | Download mode | `ytdlp` |
|
||||
| **Template Options** | | |
|
||||
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
|
||||
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
|
||||
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
|
||||
| `--playlist-folder-template` | Playlist folder template | `Playlists/{playlist_artist}` |
|
||||
| `--single-disc-file-template` | Single disc file template | `{track:02d} {title}` |
|
||||
| `--multi-disc-file-template` | Multi disc file template | `{disc}-{track:02d} {title}` |
|
||||
| `--no-album-file-template` | No album file template | `{title}` |
|
||||
| `--playlist-file-template` | Playlist file template | `{playlist_title}` |
|
||||
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` | Comma-separated tags to exclude | - |
|
||||
| `--truncate` | Max filename length | - |
|
||||
| **File Output Options** | | |
|
||||
| `--overwrite` | Overwrite existing files | `false` |
|
||||
| `--save-cover`, `-s` | Save cover as separate file | `false` |
|
||||
| `--save-playlist` | Save M3U8 playlist file | `false` |
|
||||
|
||||
### Template Variables
|
||||
|
||||
@@ -194,10 +216,9 @@ The file is created automatically on first run. Command-line arguments override
|
||||
|
||||
- `ytdlp`, `nm3u8dlre`
|
||||
|
||||
### Remux Mode
|
||||
|
||||
- `ffmpeg`
|
||||
- `mp4box` - Preserve the original closed caption track in music videos and some other minor metadata
|
||||
> [!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.
|
||||
|
||||
### Cover Format
|
||||
|
||||
@@ -211,12 +232,12 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
|
||||
|
||||
### Song Codecs
|
||||
|
||||
**Stable:**
|
||||
**Web:**
|
||||
|
||||
- `aac-legacy` - AAC 256kbps 44.1kHz
|
||||
- `aac-he-legacy` - AAC-HE 64kbps 44.1kHz
|
||||
- `aac-web` - AAC 256kbps 44.1kHz
|
||||
- `aac-he-web` - AAC-HE 64kbps 44.1kHz
|
||||
|
||||
**Experimental** (may not work due to API limitations):
|
||||
**Non-web** (wrapper recommended; may not work without wrapper due to API limitations):
|
||||
|
||||
- `aac` - AAC 256kbps up to 48kHz
|
||||
- `aac-he` - AAC-HE 64kbps up to 48kHz
|
||||
@@ -226,8 +247,8 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
|
||||
- `aac-he-downmix` - AAC-HE 64kbps downmix
|
||||
- `atmos` - Dolby Atmos 768kbps
|
||||
- `ac3` - AC3 640kbps
|
||||
- `alac` - ALAC up to 24-bit/192kHz (unsupported)
|
||||
- `ask` - Interactive experimental codec selection
|
||||
- `alac` - ALAC up to 24-bit/192kHz
|
||||
- `ask` - Interactive codec selection
|
||||
|
||||
### Synced Lyrics Format
|
||||
|
||||
@@ -265,16 +286,6 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
|
||||
- `top-songs`
|
||||
- `music-videos`
|
||||
|
||||
## ⚙️ Wrapper
|
||||
|
||||
Use the [wrapper](https://github.com/WorldObservationLog/wrapper) to download songs in ALAC and other experimental codecs without API limitations. Cookies are not required when using the wrapper.
|
||||
|
||||
### Setup Instructions
|
||||
|
||||
1. **Start the wrapper server** - Run the wrapper server
|
||||
2. **Enable wrapper in Gamdl** - Use `--use-wrapper` flag or set `use_wrapper = true` in config
|
||||
3. **Run Gamdl** - Download as usual with the wrapper enabled
|
||||
|
||||
## 🐍 Embedding
|
||||
|
||||
Use Gamdl as a library in your Python projects:
|
||||
@@ -282,7 +293,7 @@ Use Gamdl as a library in your Python projects:
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
from gamdl.api import AppleMusicApi, ItunesApi
|
||||
from gamdl.api import AppleMusicApi
|
||||
from gamdl.downloader import (
|
||||
AppleMusicBaseDownloader,
|
||||
AppleMusicDownloader,
|
||||
@@ -291,63 +302,79 @@ from gamdl.downloader import (
|
||||
AppleMusicUploadedVideoDownloader,
|
||||
)
|
||||
from gamdl.interface import (
|
||||
AppleMusicBaseInterface,
|
||||
AppleMusicInterface,
|
||||
AppleMusicMusicVideoInterface,
|
||||
AppleMusicSongInterface,
|
||||
AppleMusicUploadedVideoInterface,
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
# Create AppleMusicApi instance (from cookies or wrapper)
|
||||
# Create AppleMusicApi instance from cookies
|
||||
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
|
||||
cookies_path="cookies.txt",
|
||||
)
|
||||
itunes_api = ItunesApi(
|
||||
apple_music_api.storefront,
|
||||
apple_music_api.language,
|
||||
)
|
||||
|
||||
# Check subscription
|
||||
assert apple_music_api.active_subscription
|
||||
if not apple_music_api.active_subscription:
|
||||
print("No active Apple Music subscription")
|
||||
return
|
||||
|
||||
# Set up interfaces
|
||||
interface = AppleMusicInterface(apple_music_api, itunes_api)
|
||||
song_interface = AppleMusicSongInterface(interface)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(interface)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
|
||||
|
||||
# Set up base downloader and specialized downloaders
|
||||
base_downloader = AppleMusicBaseDownloader()
|
||||
song_downloader = AppleMusicSongDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=song_interface,
|
||||
)
|
||||
music_video_downloader = AppleMusicMusicVideoDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=music_video_interface,
|
||||
)
|
||||
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=uploaded_video_interface,
|
||||
# Create base interface
|
||||
base_interface = await AppleMusicBaseInterface.create(
|
||||
apple_music_api=apple_music_api,
|
||||
)
|
||||
|
||||
# Main downloader
|
||||
downloader = AppleMusicDownloader(
|
||||
# Create specialized interfaces
|
||||
song_interface = AppleMusicSongInterface(
|
||||
base=base_interface,
|
||||
)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(
|
||||
base=base_interface,
|
||||
)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(
|
||||
base=base_interface,
|
||||
)
|
||||
|
||||
# Create main interface
|
||||
interface = AppleMusicInterface(
|
||||
song=song_interface,
|
||||
music_video=music_video_interface,
|
||||
uploaded_video=uploaded_video_interface,
|
||||
)
|
||||
|
||||
# Create base downloader
|
||||
base_downloader = AppleMusicBaseDownloader(
|
||||
interface=interface,
|
||||
base_downloader=base_downloader,
|
||||
song_downloader=song_downloader,
|
||||
music_video_downloader=music_video_downloader,
|
||||
uploaded_video_downloader=uploaded_video_downloader,
|
||||
)
|
||||
|
||||
# Download a song
|
||||
# Create specialized downloaders
|
||||
song_downloader = AppleMusicSongDownloader(base=base_downloader)
|
||||
music_video_downloader = AppleMusicMusicVideoDownloader(
|
||||
base=base_downloader,
|
||||
)
|
||||
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(base=base_downloader)
|
||||
|
||||
# Create main downloader
|
||||
downloader = AppleMusicDownloader(
|
||||
song=song_downloader,
|
||||
music_video=music_video_downloader,
|
||||
uploaded_video=uploaded_video_downloader,
|
||||
)
|
||||
|
||||
# Download from URL
|
||||
url = "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
|
||||
url_info = downloader.get_url_info(url)
|
||||
if url_info:
|
||||
download_queue = await downloader.get_download_queue(url_info)
|
||||
if download_queue:
|
||||
for download_item in download_queue:
|
||||
await downloader.download(download_item)
|
||||
|
||||
download_queue = []
|
||||
async for media in downloader.get_download_item_from_url(url):
|
||||
download_queue.append(media)
|
||||
|
||||
for download_item in download_queue:
|
||||
try:
|
||||
await downloader.download(download_item)
|
||||
except Exception as e:
|
||||
print(f"Error downloading: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "2.9.2"
|
||||
__version__ = "3.7"
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
from .apple_music_api import AppleMusicApi
|
||||
from .itunes_api import ItunesApi
|
||||
from .apple_music import AppleMusicApi
|
||||
from .exceptions import *
|
||||
from .itunes import ItunesApi
|
||||
from .wrapper import WrapperApi
|
||||
|
||||
@@ -0,0 +1,753 @@
|
||||
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_LIBRARY_PLAYLISTS_API_URI,
|
||||
APPLE_MUSIC_LICENSE_API_URL,
|
||||
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI,
|
||||
APPLE_MUSIC_MUSIC_VIDEO_API_URI,
|
||||
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI,
|
||||
APPLE_MUSIC_PLAYLIST_API_URI,
|
||||
APPLE_MUSIC_SEARCH_API_URI,
|
||||
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI,
|
||||
APPLE_MUSIC_LIBRARY_SONG_API_URI,
|
||||
APPLE_MUSIC_LIBRARY_SONGS_API_URI,
|
||||
APPLE_MUSIC_SONG_API_URI,
|
||||
APPLE_MUSIC_UPLOADED_VIDEO_API_URL,
|
||||
APPLE_MUSIC_WEBPLAYBACK_API_URL,
|
||||
)
|
||||
from .exceptions import GamdlApiResponseError
|
||||
from .wrapper import WrapperApi
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicApi:
|
||||
def __init__(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
token: str,
|
||||
storefront: str,
|
||||
language: str,
|
||||
media_user_token: str | None = None,
|
||||
account_info: dict | None = None,
|
||||
) -> None:
|
||||
self.token = token
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
self.media_user_token = media_user_token
|
||||
self.account_info = account_info
|
||||
self.client = client
|
||||
|
||||
@property
|
||||
def active_subscription(self) -> bool:
|
||||
if not self.account_info:
|
||||
return False
|
||||
|
||||
return (
|
||||
self.account_info.get("meta", {})
|
||||
.get("subscription", {})
|
||||
.get("active", False)
|
||||
)
|
||||
|
||||
@property
|
||||
def account_restrictions(self) -> dict | None:
|
||||
if not self.account_info:
|
||||
return None
|
||||
|
||||
data = self.account_info.get("data", [])
|
||||
if not data:
|
||||
return None
|
||||
return data[0].get("attributes", {}).get("restrictions")
|
||||
|
||||
@staticmethod
|
||||
async def get_token() -> str:
|
||||
log = logger.bind(action="get_token")
|
||||
|
||||
response = None
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
APPLE_MUSIC_HOMEPAGE_URL,
|
||||
follow_redirects=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
home_page = response.text
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching Apple Music homepage",
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
index_js_uri_match = re.search(
|
||||
r"/(assets/index-legacy[~-][^/\"]+\.js)",
|
||||
home_page,
|
||||
)
|
||||
if not index_js_uri_match:
|
||||
raise GamdlApiResponseError(
|
||||
"Error finding index.js URI in Apple Music homepage"
|
||||
)
|
||||
index_js_uri = index_js_uri_match.group(1)
|
||||
|
||||
response = None
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
index_js_page = response.text
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching index.js page",
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
token_match = re.search('(?=eyJh)(.*?)(?=")', index_js_page)
|
||||
if not token_match:
|
||||
raise GamdlApiResponseError("Error finding token in index.js page")
|
||||
token = token_match.group(1)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
async def get_account_info(
|
||||
token: str,
|
||||
media_user_token: str,
|
||||
meta: str = "subscription",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_account_info", meta=meta)
|
||||
|
||||
response = None
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
APPLE_MUSIC_AMP_API_URL + APPLE_MUSIC_ACCOUNT_INFO_API_URI,
|
||||
params={
|
||||
"meta": meta,
|
||||
},
|
||||
headers={
|
||||
"authorization": f"Bearer {token}",
|
||||
"origin": APPLE_MUSIC_HOMEPAGE_URL,
|
||||
"cookie": f"media-user-token={media_user_token}",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
account_info = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching account info",
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
log.debug("success", account_info=account_info)
|
||||
|
||||
return account_info
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
storefront: str | None = "us",
|
||||
language: str = "en-US",
|
||||
token: str | None = None,
|
||||
media_user_token: str | None = None,
|
||||
) -> "AppleMusicApi":
|
||||
token = token or await cls.get_token()
|
||||
account_info = (
|
||||
await cls.get_account_info(token, media_user_token)
|
||||
if media_user_token
|
||||
else None
|
||||
)
|
||||
storefront = (
|
||||
account_info["meta"]["subscription"]["storefront"]
|
||||
if account_info
|
||||
else storefront
|
||||
)
|
||||
if not storefront:
|
||||
raise ValueError(
|
||||
"Storefront must be provided if it cannot be determined from account info"
|
||||
)
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
headers={
|
||||
"authorization": f"Bearer {token}",
|
||||
"origin": APPLE_MUSIC_HOMEPAGE_URL,
|
||||
},
|
||||
transport=RetryTransport(
|
||||
retry=Retry(
|
||||
total=6,
|
||||
backoff_factor=1,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
if media_user_token:
|
||||
client.headers.update(
|
||||
{
|
||||
"cookie": f"media-user-token={media_user_token}",
|
||||
}
|
||||
)
|
||||
|
||||
api = cls(
|
||||
client=client,
|
||||
token=token,
|
||||
storefront=storefront,
|
||||
language=language,
|
||||
media_user_token=media_user_token,
|
||||
account_info=account_info,
|
||||
)
|
||||
return api
|
||||
|
||||
@classmethod
|
||||
async def create_from_netscape_cookies(
|
||||
cls,
|
||||
cookies_path: str = "./cookies.txt",
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "AppleMusicApi":
|
||||
cookies = MozillaCookieJar(cookies_path)
|
||||
cookies.load(ignore_discard=True, ignore_expires=True)
|
||||
parse_cookie = lambda name: next(
|
||||
(
|
||||
cookie.value
|
||||
for cookie in cookies
|
||||
if cookie.name == name and cookie.domain == APPLE_MUSIC_COOKIE_DOMAIN
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
media_user_token = parse_cookie("media-user-token")
|
||||
if not media_user_token:
|
||||
raise ValueError(
|
||||
'"media-user-token" cookie not found in cookies. '
|
||||
"Make sure you have exported the cookies from the Apple Music webpage "
|
||||
"and are logged in with an active subscription."
|
||||
)
|
||||
|
||||
return await cls.create(
|
||||
media_user_token=media_user_token,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def create_from_wrapper(
|
||||
cls,
|
||||
wrapper_api: WrapperApi,
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "AppleMusicApi":
|
||||
auth = wrapper_api.me.get("auth", {})
|
||||
media_user_token = auth.get("music_user_token")
|
||||
token = auth.get("dev_token")
|
||||
if not media_user_token or not token:
|
||||
raise GamdlApiResponseError(
|
||||
"Wrapper account info is missing auth tokens",
|
||||
status_code=None,
|
||||
)
|
||||
|
||||
return await cls.create(
|
||||
media_user_token=media_user_token,
|
||||
token=token,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _amp_request(
|
||||
self,
|
||||
uri: str,
|
||||
params: dict | None = None,
|
||||
) -> dict:
|
||||
response = None
|
||||
try:
|
||||
response = await self.client.get(
|
||||
APPLE_MUSIC_AMP_API_URL + uri,
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching from AMP API",
|
||||
content=response.text if response is not None else None,
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
if "errors" in response_json:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching from AMP API",
|
||||
content=response_json["errors"],
|
||||
)
|
||||
|
||||
return response_json
|
||||
|
||||
async def get_song(
|
||||
self,
|
||||
song_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
include: str = "lyrics,albums",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_song", song_id=song_id)
|
||||
|
||||
song = await self._amp_request(
|
||||
APPLE_MUSIC_SONG_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
song_id=song_id,
|
||||
),
|
||||
{
|
||||
"extend": extend,
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", song=song)
|
||||
|
||||
return song
|
||||
|
||||
async def get_music_video(
|
||||
self,
|
||||
music_video_id: str,
|
||||
include: str = "albums",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_music_video", music_video_id=music_video_id)
|
||||
|
||||
music_video = await self._amp_request(
|
||||
APPLE_MUSIC_MUSIC_VIDEO_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
music_video_id=music_video_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", music_video=music_video)
|
||||
|
||||
return music_video
|
||||
|
||||
async def get_uploaded_video(
|
||||
self,
|
||||
uploaded_video_id: str,
|
||||
) -> dict:
|
||||
log = logger.bind(
|
||||
action="get_uploaded_video", uploaded_video_id=uploaded_video_id
|
||||
)
|
||||
|
||||
uploaded_video = await self._amp_request(
|
||||
APPLE_MUSIC_UPLOADED_VIDEO_API_URL.format(
|
||||
storefront=self.storefront,
|
||||
uploaded_video_id=uploaded_video_id,
|
||||
)
|
||||
)
|
||||
|
||||
log.debug("success", uploaded_video=uploaded_video)
|
||||
|
||||
return uploaded_video
|
||||
|
||||
async def get_album(
|
||||
self,
|
||||
album_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_album", album_id=album_id)
|
||||
|
||||
album = await self._amp_request(
|
||||
APPLE_MUSIC_ALBUM_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
album_id=album_id,
|
||||
),
|
||||
{
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", album=album)
|
||||
|
||||
return album
|
||||
|
||||
async def get_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
limit_tracks: int = 300,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_playlist", playlist_id=playlist_id)
|
||||
|
||||
playlist = await self._amp_request(
|
||||
APPLE_MUSIC_PLAYLIST_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
playlist_id=playlist_id,
|
||||
),
|
||||
{
|
||||
"limit[tracks]": limit_tracks,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", playlist=playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def get_artist(
|
||||
self,
|
||||
artist_id: str,
|
||||
include: str = "albums,music-videos",
|
||||
views: str = "full-albums,compilation-albums,live-albums,singles,top-songs",
|
||||
limit: int = 100,
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_artist", artist_id=artist_id)
|
||||
|
||||
artist = await self._amp_request(
|
||||
APPLE_MUSIC_ARTIST_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
artist_id=artist_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
"views": views,
|
||||
**{
|
||||
f"limit[{_include}]": limit
|
||||
for _include in [*include.split(","), *views.split(",")]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", artist=artist)
|
||||
|
||||
return artist
|
||||
|
||||
async def get_library_song(
|
||||
self,
|
||||
song_id: str,
|
||||
include: str = "catalog",
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_song", song_id=song_id)
|
||||
|
||||
song = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_SONG_API_URI.format(
|
||||
song_id=song_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", song=song)
|
||||
|
||||
return song
|
||||
|
||||
async def get_library_music_video(
|
||||
self,
|
||||
music_video_id: str,
|
||||
include: str = "catalog",
|
||||
) -> dict:
|
||||
log = logger.bind(
|
||||
action="get_library_music_video", music_video_id=music_video_id
|
||||
)
|
||||
|
||||
music_video = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI.format(
|
||||
music_video_id=music_video_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", music_video=music_video)
|
||||
|
||||
return music_video
|
||||
|
||||
async def get_library_album(
|
||||
self,
|
||||
album_id: str,
|
||||
include: str = "catalog",
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_album", album_id=album_id)
|
||||
|
||||
album = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_ALBUM_API_URI.format(
|
||||
album_id=album_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", album=album)
|
||||
|
||||
return album
|
||||
|
||||
async def get_library_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
include: str = "catalog,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_library_songs(
|
||||
self,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
include: str = "catalog",
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_songs")
|
||||
|
||||
library_songs = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_SONGS_API_URI,
|
||||
{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"include": include,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", library_songs=library_songs)
|
||||
|
||||
return library_songs
|
||||
|
||||
async def get_library_music_videos(
|
||||
self,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
include: str = "catalog",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_music_videos")
|
||||
|
||||
library_music_videos = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI,
|
||||
{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", library_music_videos=library_music_videos)
|
||||
|
||||
return library_music_videos
|
||||
|
||||
async def get_library_albums(
|
||||
self,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
include: str = "catalog",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_albums")
|
||||
|
||||
library_albums = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI,
|
||||
{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", library_albums=library_albums)
|
||||
|
||||
return library_albums
|
||||
|
||||
async def get_library_playlists(
|
||||
self,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
include: str = "catalog",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_playlists")
|
||||
|
||||
library_playlists = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI,
|
||||
{
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", library_playlists=library_playlists)
|
||||
|
||||
return library_playlists
|
||||
|
||||
async def get_search_results(
|
||||
self,
|
||||
term: str,
|
||||
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
|
||||
|
||||
extended_data = await self._amp_request(
|
||||
urlparse(next_uri).path,
|
||||
{
|
||||
**({"limit": limit} if limit else {}),
|
||||
**{k: v for k, v in next_params.items() if k not in ["limit"]},
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", extended_data=extended_data)
|
||||
|
||||
return extended_data
|
||||
|
||||
async def get_webplayback(
|
||||
self,
|
||||
track_id: str,
|
||||
is_library: bool = False,
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_webplayback", track_id=track_id)
|
||||
|
||||
response = None
|
||||
|
||||
if is_library:
|
||||
request_body = {
|
||||
"universalLibraryId": track_id,
|
||||
}
|
||||
else:
|
||||
request_body = {
|
||||
"salableAdamId": track_id,
|
||||
}
|
||||
request_body["language"] = self.language
|
||||
|
||||
try:
|
||||
response = await self.client.post(
|
||||
APPLE_MUSIC_WEBPLAYBACK_API_URL,
|
||||
json=request_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
webplayback = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching webplayback data",
|
||||
content=response.text if response is not None else None,
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
if "dialog" in webplayback:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching webplayback data",
|
||||
content=webplayback["dialog"],
|
||||
)
|
||||
|
||||
log.debug("success", webplayback=webplayback)
|
||||
|
||||
return webplayback
|
||||
|
||||
async def get_license_exchange(
|
||||
self,
|
||||
track_id: str,
|
||||
track_uri: str,
|
||||
challenge: str,
|
||||
key_system: str = "com.widevine.alpha",
|
||||
is_library: bool = False,
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_license_exchange", track_id=track_id)
|
||||
|
||||
response = None
|
||||
try:
|
||||
response = await self.client.post(
|
||||
APPLE_MUSIC_LICENSE_API_URL,
|
||||
json={
|
||||
"challenge": challenge,
|
||||
"key-system": key_system,
|
||||
"uri": track_uri,
|
||||
"adamId": track_id,
|
||||
"isLibrary": is_library,
|
||||
"user-initiated": True,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
license_exchange = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching license exchange data",
|
||||
content=response.text if response is not None else None,
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
if license_exchange.get("status") != 0:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching license exchange data",
|
||||
content=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
log.debug("success", license_exchange=license_exchange)
|
||||
|
||||
return license_exchange
|
||||
@@ -1,467 +0,0 @@
|
||||
import logging
|
||||
import re
|
||||
import typing
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ..utils import get_response, raise_for_status, safe_json
|
||||
from .constants import (
|
||||
AMP_API_URL,
|
||||
APPLE_MUSIC_COOKIE_DOMAIN,
|
||||
APPLE_MUSIC_HOMEPAGE_URL,
|
||||
LICENSE_API_URL,
|
||||
WEBPLAYBACK_API_URL,
|
||||
)
|
||||
from .exceptions import ApiError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleMusicApi:
|
||||
def __init__(
|
||||
self,
|
||||
storefront: str = "us",
|
||||
language: str = "en-US",
|
||||
media_user_token: str | None = None,
|
||||
developer_token: str | None = None,
|
||||
) -> None:
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
self.media_user_token = media_user_token
|
||||
self.token = developer_token
|
||||
|
||||
@classmethod
|
||||
async def create_from_netscape_cookies(
|
||||
cls,
|
||||
cookies_path: str = "./cookies.txt",
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "AppleMusicApi":
|
||||
cookies = MozillaCookieJar(cookies_path)
|
||||
cookies.load(ignore_discard=True, ignore_expires=True)
|
||||
parse_cookie = lambda name: next(
|
||||
(
|
||||
cookie.value
|
||||
for cookie in cookies
|
||||
if cookie.name == name and cookie.domain == APPLE_MUSIC_COOKIE_DOMAIN
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
media_user_token = parse_cookie("media-user-token")
|
||||
if not media_user_token:
|
||||
raise ValueError(
|
||||
'"media-user-token" cookie not found in cookies. '
|
||||
"Make sure you have exported the cookies from the Apple Music webpage "
|
||||
"and are logged in with an active subscription."
|
||||
)
|
||||
|
||||
return await cls.create(
|
||||
storefront=None,
|
||||
media_user_token=media_user_token,
|
||||
developer_token=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def create_from_wrapper(
|
||||
cls,
|
||||
wrapper_account_url: str = "http://127.0.0.1:30020/",
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "AppleMusicApi":
|
||||
wrapper_account_response = await get_response(wrapper_account_url)
|
||||
wrapper_account_info = safe_json(wrapper_account_response)
|
||||
|
||||
return await cls.create(
|
||||
storefront=None,
|
||||
media_user_token=wrapper_account_info["music_token"],
|
||||
developer_token=wrapper_account_info["dev_token"],
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
storefront: str | None = "us",
|
||||
language: str = "en-US",
|
||||
media_user_token: str | None = None,
|
||||
developer_token: str | None = None,
|
||||
) -> "AppleMusicApi":
|
||||
api = cls(
|
||||
storefront=storefront,
|
||||
language=language,
|
||||
media_user_token=media_user_token,
|
||||
developer_token=developer_token,
|
||||
)
|
||||
await api.initialize()
|
||||
return api
|
||||
|
||||
async def initialize(self) -> None:
|
||||
await self._initialize_client()
|
||||
await self._initialize_token()
|
||||
await self._initialize_account_info()
|
||||
|
||||
async def _initialize_client(self) -> None:
|
||||
self.client = httpx.AsyncClient(
|
||||
headers={
|
||||
"accept": "*/*",
|
||||
"accept-language": "en-US",
|
||||
"origin": APPLE_MUSIC_HOMEPAGE_URL,
|
||||
"priority": "u=1, i",
|
||||
"referer": APPLE_MUSIC_HOMEPAGE_URL,
|
||||
"sec-ch-ua": '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-site",
|
||||
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
||||
},
|
||||
params={
|
||||
"l": self.language,
|
||||
},
|
||||
follow_redirects=True,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
async def _get_token(self) -> str:
|
||||
response = await self.client.get(APPLE_MUSIC_HOMEPAGE_URL)
|
||||
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}")
|
||||
index_js_page = response.text
|
||||
|
||||
token_match = re.search('(?=eyJh)(.*?)(?=")', index_js_page)
|
||||
if not token_match:
|
||||
raise Exception("Token not found in index.js page")
|
||||
token = token_match.group(1)
|
||||
|
||||
logger.debug(f"Token: {token}")
|
||||
return token
|
||||
|
||||
async def _initialize_token(self) -> None:
|
||||
self.token = self.token or await self._get_token()
|
||||
self.client.headers.update({"authorization": f"Bearer {self.token}"})
|
||||
|
||||
async def _initialize_account_info(self) -> None:
|
||||
if not self.media_user_token:
|
||||
return
|
||||
|
||||
self.client.cookies.update(
|
||||
{
|
||||
"media-user-token": self.media_user_token,
|
||||
}
|
||||
)
|
||||
|
||||
self.account_info = await self.get_account_info()
|
||||
self.storefront = self.account_info["meta"]["subscription"]["storefront"]
|
||||
|
||||
@property
|
||||
def active_subscription(self) -> bool:
|
||||
return (
|
||||
getattr(self, "account_info", {})
|
||||
.get("meta", {})
|
||||
.get("subscription", {})
|
||||
.get("active", False)
|
||||
)
|
||||
|
||||
@property
|
||||
def account_restrictions(self) -> dict | None:
|
||||
data = getattr(self, "account_info", {}).get("data", [])
|
||||
if not data:
|
||||
return None
|
||||
return data[0].get("attributes", {}).get("restrictions")
|
||||
|
||||
async def get_account_info(self, meta: str = "subscription") -> dict:
|
||||
account_info = await self._amp_request(
|
||||
f"/v1/me/account",
|
||||
{
|
||||
"meta": meta,
|
||||
},
|
||||
)
|
||||
logger.debug(f"Account info: {account_info}")
|
||||
|
||||
return account_info
|
||||
|
||||
async def _amp_request(
|
||||
self,
|
||||
endpoint: str,
|
||||
params: dict | None = None,
|
||||
) -> dict:
|
||||
response = await self.client.get(
|
||||
AMP_API_URL + endpoint,
|
||||
params=params or {},
|
||||
)
|
||||
response_json = safe_json(response)
|
||||
|
||||
if (
|
||||
response.status_code != 200
|
||||
or response_json is None
|
||||
or "errors" in response_json
|
||||
):
|
||||
raise ApiError(
|
||||
message=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
return response_json
|
||||
|
||||
async def get_song(
|
||||
self,
|
||||
song_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
include: str = "lyrics,albums",
|
||||
) -> dict | None:
|
||||
song = await self._amp_request(
|
||||
f"/v1/catalog/{self.storefront}/songs/{song_id}",
|
||||
{
|
||||
"extend": extend,
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
logger.debug(f"Song: {song}")
|
||||
|
||||
return song
|
||||
|
||||
async def get_music_video(
|
||||
self,
|
||||
music_video_id: str,
|
||||
include: str = "albums",
|
||||
) -> dict | None:
|
||||
music_video = await self._amp_request(
|
||||
f"/v1/catalog/{self.storefront}/music-videos/{music_video_id}",
|
||||
{
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
logger.debug(f"Music video: {music_video}")
|
||||
|
||||
return music_video
|
||||
|
||||
async def get_uploaded_video(
|
||||
self,
|
||||
post_id: str,
|
||||
) -> dict | None:
|
||||
uploaded_video = await self._amp_request(
|
||||
f"/v1/catalog/{self.storefront}/uploaded-videos/{post_id}",
|
||||
)
|
||||
logger.debug(f"Uploaded video: {uploaded_video}")
|
||||
|
||||
return uploaded_video
|
||||
|
||||
async def get_album(
|
||||
self,
|
||||
album_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict | None:
|
||||
album = await self._amp_request(
|
||||
f"/v1/catalog/{self.storefront}/albums/{album_id}",
|
||||
{
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
logger.debug(f"Album: {album}")
|
||||
|
||||
return album
|
||||
|
||||
async def get_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
limit_tracks: int = 300,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict | None:
|
||||
playlist = await self._amp_request(
|
||||
f"/v1/catalog/{self.storefront}/playlists/{playlist_id}",
|
||||
{
|
||||
"limit[tracks]": limit_tracks,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
logger.debug(f"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 | None:
|
||||
artist = await self._amp_request(
|
||||
f"/v1/catalog/{self.storefront}/artists/{artist_id}",
|
||||
{
|
||||
"include": include,
|
||||
"views": views,
|
||||
**{
|
||||
f"limit[{_include}]": limit
|
||||
for _include in [*include.split(","), *views.split(",")]
|
||||
},
|
||||
},
|
||||
)
|
||||
logger.debug(f"Artist: {artist}")
|
||||
|
||||
return artist
|
||||
|
||||
async def get_library_album(
|
||||
self,
|
||||
album_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict | None:
|
||||
album = await self._amp_request(
|
||||
f"/v1/me/library/albums/{album_id}",
|
||||
{
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
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:
|
||||
playlist = await self._amp_request(
|
||||
f"/v1/me/library/playlists/{playlist_id}",
|
||||
{
|
||||
"include": include,
|
||||
**{f"limit[{_include}]": limit for _include in include.split(",")},
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
logger.debug(f"Library 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:
|
||||
search_results = await self._amp_request(
|
||||
f"/v1/catalog/{self.storefront}/search",
|
||||
{
|
||||
"term": term,
|
||||
"types": types,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
)
|
||||
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:
|
||||
next_uri_params = parse_qs(urlparse(next_uri).query)
|
||||
params = {
|
||||
"limit": limit,
|
||||
"offset": next_uri_params["offset"][0],
|
||||
"extend": extend,
|
||||
}
|
||||
extended_api_data = await self._amp_request(next_uri, params)
|
||||
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,
|
||||
},
|
||||
)
|
||||
webplayback = safe_json(response)
|
||||
|
||||
if (
|
||||
response.status_code != 200
|
||||
or webplayback is None
|
||||
or "dialog" in webplayback
|
||||
):
|
||||
raise ApiError(
|
||||
message=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
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,
|
||||
},
|
||||
)
|
||||
license_exchange = safe_json(response)
|
||||
|
||||
if (
|
||||
response.status_code != 200
|
||||
or license_exchange is None
|
||||
or license_exchange.get("status") != 0
|
||||
):
|
||||
raise ApiError(
|
||||
message=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
logger.debug(f"License exchange: {license_exchange}")
|
||||
|
||||
return license_exchange
|
||||
+34
-162
@@ -1,170 +1,42 @@
|
||||
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_SONG_API_URI = "/v1/me/library/songs/{song_id}"
|
||||
APPLE_MUSIC_LIBRARY_MUSIC_VIDEO_API_URI = (
|
||||
"/v1/me/library/music-videos/{music_video_id}"
|
||||
)
|
||||
APPLE_MUSIC_LIBRARY_ALBUM_API_URI = "/v1/me/library/albums/{album_id}"
|
||||
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI = "/v1/me/library/playlists/{playlist_id}"
|
||||
APPLE_MUSIC_SEARCH_API_URI = "/v1/catalog/{storefront}/search"
|
||||
APPLE_MUSIC_LIBRARY_SONGS_API_URI = "/v1/me/library/songs"
|
||||
APPLE_MUSIC_LIBRARY_MUSIC_VIDEOS_API_URI = "/v1/me/library/music-videos"
|
||||
APPLE_MUSIC_LIBRARY_ALBUMS_API_URI = "/v1/me/library/albums"
|
||||
APPLE_MUSIC_LIBRARY_PLAYLISTS_API_URI = "/v1/me/library/playlists"
|
||||
|
||||
APPLE_MUSIC_WEBPLAYBACK_API_URL = (
|
||||
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
|
||||
)
|
||||
LICENSE_API_URL = (
|
||||
|
||||
APPLE_MUSIC_LICENSE_API_URL = (
|
||||
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense"
|
||||
)
|
||||
|
||||
APPLE_MUSIC_MUSIC_KIT_URL = (
|
||||
"https://music.apple.com/includes/js-cdn/musickit/v3/amp/musickit.js"
|
||||
)
|
||||
|
||||
ITUNES_LOOKUP_API_URL = "https://itunes.apple.com/lookup"
|
||||
ITUNES_PAGE_API_URL = "https://music.apple.com"
|
||||
STOREFRONT_IDS = {
|
||||
"AE": "143481-2,32",
|
||||
"AG": "143540-2,32",
|
||||
"AI": "143538-2,32",
|
||||
"AL": "143575-2,32",
|
||||
"AM": "143524-2,32",
|
||||
"AO": "143564-2,32",
|
||||
"AR": "143505-28,32",
|
||||
"AT": "143445-4,32",
|
||||
"AU": "143460-27,32",
|
||||
"AZ": "143568-2,32",
|
||||
"BB": "143541-2,32",
|
||||
"BE": "143446-2,32",
|
||||
"BF": "143578-2,32",
|
||||
"BG": "143526-2,32",
|
||||
"BH": "143559-2,32",
|
||||
"BJ": "143576-2,32",
|
||||
"BM": "143542-2,32",
|
||||
"BN": "143560-2,32",
|
||||
"BO": "143556-28,32",
|
||||
"BR": "143503-15,32",
|
||||
"BS": "143539-2,32",
|
||||
"BT": "143577-2,32",
|
||||
"BW": "143525-2,32",
|
||||
"BY": "143565-2,32",
|
||||
"BZ": "143555-2,32",
|
||||
"CA": "143455-6,32",
|
||||
"CG": "143582-2,32",
|
||||
"CH": "143459-57,32",
|
||||
"CM": "143574-2,32",
|
||||
"CL": "143483-28,32",
|
||||
"CN": "143465-19,32",
|
||||
"CO": "143501-28,32",
|
||||
"CR": "143495-28,32",
|
||||
"CV": "143580-2,32",
|
||||
"CY": "143557-2,32",
|
||||
"CZ": "143489-2,32",
|
||||
"DE": "143443-4,32",
|
||||
"DK": "143458-2,32",
|
||||
"DM": "143545-2,32",
|
||||
"DO": "143508-28,32",
|
||||
"DZ": "143563-2,32",
|
||||
"EC": "143509-28,32",
|
||||
"EE": "143518-2,32",
|
||||
"EG": "143516-2,32",
|
||||
"ES": "143454-8,32",
|
||||
"FI": "143447-2,32",
|
||||
"FJ": "143583-2,32",
|
||||
"FM": "143591-2,32",
|
||||
"FR": "143442-3,32",
|
||||
"GB": "143444-2,32",
|
||||
"GD": "143546-2,32",
|
||||
"GH": "143573-2,32",
|
||||
"GM": "143584-2,32",
|
||||
"GR": "143448-2,32",
|
||||
"GT": "143504-28,32",
|
||||
"GW": "143585-2,32",
|
||||
"GY": "143553-2,32",
|
||||
"HK": "143463-45,32",
|
||||
"HN": "143510-28,32",
|
||||
"HR": "143494-2,32",
|
||||
"HU": "143482-2,32",
|
||||
"ID": "143476-2,32",
|
||||
"IE": "143449-2,32",
|
||||
"IL": "143491-2,32",
|
||||
"IN": "143467-2,32",
|
||||
"IS": "143558-2,32",
|
||||
"IT": "143450-7,32",
|
||||
"JM": "143511-2,32",
|
||||
"JO": "143528-2,32",
|
||||
"JP": "143462-9,32",
|
||||
"KE": "143529-2,32",
|
||||
"KG": "143586-2,32",
|
||||
"KH": "143579-2,32",
|
||||
"KN": "143548-2,32",
|
||||
"KR": "143466-13,32",
|
||||
"KW": "143493-2,32",
|
||||
"KY": "143544-2,32",
|
||||
"KZ": "143517-2,32",
|
||||
"LA": "143587-2,32",
|
||||
"LB": "143497-2,32",
|
||||
"LC": "143549-2,32",
|
||||
"LK": "143486-2,32",
|
||||
"LR": "143588-2,32",
|
||||
"LT": "143520-2,32",
|
||||
"LU": "143451-2,32",
|
||||
"LV": "143519-2,32",
|
||||
"MD": "143523-2,32",
|
||||
"MG": "143531-2,32",
|
||||
"MK": "143530-2,32",
|
||||
"ML": "143532-2,32",
|
||||
"MN": "143592-2,32",
|
||||
"MO": "143515-45,32",
|
||||
"MR": "143590-2,32",
|
||||
"MS": "143547-2,32",
|
||||
"MT": "143521-2,32",
|
||||
"MU": "143533-2,32",
|
||||
"MW": "143589-2,32",
|
||||
"MX": "143468-28,32",
|
||||
"MY": "143473-2,32",
|
||||
"MZ": "143593-2,32",
|
||||
"NA": "143594-2,32",
|
||||
"NE": "143534-2,32",
|
||||
"NG": "143561-2,32",
|
||||
"NI": "143512-28,32",
|
||||
"NL": "143452-10,32",
|
||||
"NO": "143457-2,32",
|
||||
"NP": "143484-2,32",
|
||||
"NZ": "143461-27,32",
|
||||
"OM": "143562-2,32",
|
||||
"PA": "143485-28,32",
|
||||
"PE": "143507-28,32",
|
||||
"PG": "143597-2,32",
|
||||
"PH": "143474-2,32",
|
||||
"PK": "143477-2,32",
|
||||
"PL": "143478-2,32",
|
||||
"PT": "143453-24,32",
|
||||
"PW": "143595-2,32",
|
||||
"PY": "143513-28,32",
|
||||
"QA": "143498-2,32",
|
||||
"RO": "143487-2,32",
|
||||
"RU": "143469-16,32",
|
||||
"SA": "143479-2,32",
|
||||
"SB": "143601-2,32",
|
||||
"SC": "143599-2,32",
|
||||
"SE": "143456-17,32",
|
||||
"SG": "143464-19,32",
|
||||
"SI": "143499-2,32",
|
||||
"SK": "143496-2,32",
|
||||
"SL": "143600-2,32",
|
||||
"SN": "143535-2,32",
|
||||
"SR": "143554-2,32",
|
||||
"ST": "143598-2,32",
|
||||
"SV": "143506-28,32",
|
||||
"SZ": "143602-2,32",
|
||||
"TC": "143552-2,32",
|
||||
"TD": "143581-2,32",
|
||||
"TH": "143475-2,32",
|
||||
"TJ": "143603-2,32",
|
||||
"TM": "143604-2,32",
|
||||
"TN": "143536-2,32",
|
||||
"TR": "143480-2,32",
|
||||
"TT": "143551-2,32",
|
||||
"TW": "143470-18,32",
|
||||
"TZ": "143572-2,32",
|
||||
"UA": "143492-2,32",
|
||||
"UG": "143537-2,32",
|
||||
"US": "143441-1,32",
|
||||
"UY": "143514-2,32",
|
||||
"UZ": "143566-2,32",
|
||||
"VC": "143550-2,32",
|
||||
"VE": "143502-28,32",
|
||||
"VG": "143543-2,32",
|
||||
"VN": "143471-2,32",
|
||||
"YE": "143571-2,32",
|
||||
"ZA": "143472-2,32",
|
||||
"ZW": "143605-2,32",
|
||||
}
|
||||
ITUNES_PAGE_API_URL = "https://music.apple.com/{media_type}/{media_id}"
|
||||
|
||||
+21
-3
@@ -1,7 +1,25 @@
|
||||
from ..utils import GamdlError
|
||||
|
||||
|
||||
class ApiError(GamdlError):
|
||||
def __init__(self, message: str, status_code: int):
|
||||
super().__init__(f"API Error {status_code}: {message}")
|
||||
class GamdlApiError(GamdlError):
|
||||
pass
|
||||
|
||||
|
||||
class GamdlApiResponseError(GamdlApiError):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
content: str | None = None,
|
||||
status_code: int | None = None,
|
||||
):
|
||||
self.message = message
|
||||
self.content = content
|
||||
self.status_code = status_code
|
||||
|
||||
if status_code is not None:
|
||||
message = f"{message} (Status code: {status_code})"
|
||||
|
||||
if content:
|
||||
message += f": {content}"
|
||||
|
||||
super().__init__(message)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import re
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from .constants import (
|
||||
APPLE_MUSIC_MUSIC_KIT_URL,
|
||||
ITUNES_LOOKUP_API_URL,
|
||||
ITUNES_PAGE_API_URL,
|
||||
)
|
||||
from .exceptions import GamdlApiResponseError
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ItunesApi:
|
||||
def __init__(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
storefront: str,
|
||||
language: str,
|
||||
storefront_id: int,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
self.storefront_id = storefront_id
|
||||
|
||||
@staticmethod
|
||||
async def get_storefront_id(storefront: str) -> int:
|
||||
log = logger.bind(action="get_storefront_id", storefront=storefront)
|
||||
|
||||
response = None
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(APPLE_MUSIC_MUSIC_KIT_URL)
|
||||
response.raise_for_status()
|
||||
music_kit_content = response.text
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching MusicKit content",
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
normalized_storefront = storefront.upper()
|
||||
|
||||
country_code_pattern = f'{normalized_storefront}:"([A-Z]{{3}})"'
|
||||
country_code_match = re.search(country_code_pattern, music_kit_content)
|
||||
if not country_code_match:
|
||||
raise GamdlApiResponseError(
|
||||
f"Country code {storefront} not found in MusicKit content"
|
||||
)
|
||||
|
||||
three_letter_code = country_code_match.group(1)
|
||||
|
||||
storefront_pattern = f'{three_letter_code}:"(\\d+)"'
|
||||
storefront_match = re.search(storefront_pattern, music_kit_content)
|
||||
if not storefront_match:
|
||||
raise GamdlApiResponseError(
|
||||
f"Storefront ID not found for country code {storefront}"
|
||||
)
|
||||
|
||||
storefront_id = int(storefront_match.group(1))
|
||||
|
||||
log.debug("success", storefront_id=storefront_id)
|
||||
|
||||
return storefront_id
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
storefront: str = "us",
|
||||
storefront_id: int | None = 143441,
|
||||
language: str = "en-US",
|
||||
) -> "ItunesApi":
|
||||
storefront_id = storefront_id or await cls.get_storefront_id(storefront)
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
timeout=60.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
return cls(
|
||||
client=client,
|
||||
storefront=storefront,
|
||||
language=language,
|
||||
storefront_id=storefront_id,
|
||||
)
|
||||
|
||||
async def get_lookup_result(
|
||||
self,
|
||||
media_id: str,
|
||||
entity: str = "album",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_lookup_result", media_id=media_id, entity=entity)
|
||||
|
||||
response = None
|
||||
try:
|
||||
response = await self.client.get(
|
||||
ITUNES_LOOKUP_API_URL,
|
||||
params={
|
||||
"id": media_id,
|
||||
"entity": entity,
|
||||
"country": self.storefront,
|
||||
"lang": self.language,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
lookup_result = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching iTunes lookup result",
|
||||
content=response.text if response is not None else None,
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
log.debug("success", lookup_result=lookup_result)
|
||||
|
||||
return lookup_result
|
||||
|
||||
async def get_itunes_page(
|
||||
self,
|
||||
media_type: str,
|
||||
media_id: str,
|
||||
) -> dict:
|
||||
log = logger.bind(
|
||||
action="get_itunes_page",
|
||||
media_type=media_type,
|
||||
media_id=media_id,
|
||||
)
|
||||
|
||||
response = None
|
||||
try:
|
||||
response = await self.client.get(
|
||||
ITUNES_PAGE_API_URL.format(media_type=media_type, media_id=media_id),
|
||||
headers={
|
||||
"X-Apple-Store-Front": f"{self.storefront_id},32 t:music31",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
itunes_page = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching iTunes page",
|
||||
content=response.text if response is not None else None,
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
log.debug("success", itunes_page=itunes_page)
|
||||
|
||||
return itunes_page
|
||||
@@ -1,86 +0,0 @@
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from ..utils import safe_json
|
||||
from .constants import ITUNES_LOOKUP_API_URL, ITUNES_PAGE_API_URL, STOREFRONT_IDS
|
||||
from .exceptions import ApiError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ItunesApi:
|
||||
def __init__(
|
||||
self,
|
||||
storefront: str = "us",
|
||||
language: str = "en-US",
|
||||
) -> None:
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
self.initialize()
|
||||
|
||||
def initialize(self) -> None:
|
||||
self._initialize_storefront_id()
|
||||
self._initialize_client()
|
||||
|
||||
def _initialize_storefront_id(self) -> None:
|
||||
try:
|
||||
self.storefront_id = STOREFRONT_IDS[self.storefront.upper()]
|
||||
except KeyError:
|
||||
raise Exception(f"No storefront id for {self.storefront}")
|
||||
|
||||
def _initialize_client(self) -> None:
|
||||
self.client = httpx.AsyncClient(
|
||||
params={
|
||||
"country": self.storefront,
|
||||
"lang": self.language,
|
||||
},
|
||||
headers={
|
||||
"X-Apple-Store-Front": f"{self.storefront_id} t:music31",
|
||||
},
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
async def get_lookup_result(
|
||||
self,
|
||||
media_id: str,
|
||||
entity: str = "album",
|
||||
) -> dict:
|
||||
response = await self.client.get(
|
||||
ITUNES_LOOKUP_API_URL,
|
||||
params={
|
||||
"id": media_id,
|
||||
"entity": entity,
|
||||
},
|
||||
)
|
||||
lookup_result = safe_json(response)
|
||||
|
||||
if response.status_code != 200 or lookup_result is None:
|
||||
raise ApiError(
|
||||
message=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
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}"
|
||||
)
|
||||
itunes_page = safe_json(response)
|
||||
|
||||
if response.status_code != 200 or itunes_page is None:
|
||||
raise ApiError(
|
||||
message=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
logger.debug(f"iTunes page: {itunes_page}")
|
||||
|
||||
return itunes_page
|
||||
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import struct
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TypeVar
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from .exceptions import GamdlApiResponseError
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
CredentialsFunc = (
|
||||
Callable[[], tuple[str, str]] | Callable[[], Awaitable[tuple[str, str]]]
|
||||
)
|
||||
TwoFactorCodeFunc = Callable[[], str] | Callable[[], Awaitable[str]]
|
||||
|
||||
|
||||
async def _invoke(func: Callable[[], T | Awaitable[T]]) -> T:
|
||||
result = func()
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
return result
|
||||
|
||||
|
||||
class WrapperApi:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
client: httpx.AsyncClient,
|
||||
me: dict,
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.client = client
|
||||
self.me = me
|
||||
|
||||
@staticmethod
|
||||
def build_decrypt_sample_frame(
|
||||
adam_id: str,
|
||||
skd_uri: str,
|
||||
ciphertexts: list[bytes],
|
||||
) -> bytes:
|
||||
"""Build wrapper-v2 /decrypt binary request frame."""
|
||||
adam_id_bytes = adam_id.encode("utf-8")
|
||||
skd_uri_bytes = skd_uri.encode("utf-8")
|
||||
if not adam_id_bytes:
|
||||
raise ValueError("wrapper-v2: adam_id must not be empty")
|
||||
if not skd_uri_bytes:
|
||||
raise ValueError("wrapper-v2: skd_uri must not be empty")
|
||||
if not ciphertexts:
|
||||
raise ValueError("wrapper-v2: ciphertext batch must not be empty")
|
||||
|
||||
frame = bytearray()
|
||||
frame += struct.pack(
|
||||
">III",
|
||||
len(adam_id_bytes),
|
||||
len(skd_uri_bytes),
|
||||
len(ciphertexts),
|
||||
)
|
||||
for ciphertext in ciphertexts:
|
||||
frame += struct.pack(">I", len(ciphertext))
|
||||
frame += adam_id_bytes
|
||||
frame += skd_uri_bytes
|
||||
for ciphertext in ciphertexts:
|
||||
frame += ciphertext
|
||||
return bytes(frame)
|
||||
|
||||
@staticmethod
|
||||
def parse_decrypt_sample_frame(data: bytes, expected_count: int) -> list[bytes]:
|
||||
"""Parse wrapper-v2 /decrypt binary response frame."""
|
||||
if len(data) < 4:
|
||||
raise IOError("wrapper-v2: POST /decrypt returned a truncated response")
|
||||
(sample_count,) = struct.unpack_from(">I", data, 0)
|
||||
if sample_count != expected_count:
|
||||
raise IOError(
|
||||
f"wrapper-v2: expected {expected_count} samples in response, "
|
||||
f"got {sample_count}"
|
||||
)
|
||||
|
||||
table_end = 4 + sample_count * 4
|
||||
if len(data) < table_end:
|
||||
raise IOError("wrapper-v2: POST /decrypt returned a truncated length table")
|
||||
|
||||
lengths = [
|
||||
struct.unpack_from(">I", data, 4 + i * 4)[0] for i in range(sample_count)
|
||||
]
|
||||
offset = table_end
|
||||
out: list[bytes] = []
|
||||
for i, length in enumerate(lengths):
|
||||
end = offset + length
|
||||
if end > len(data):
|
||||
raise IOError(
|
||||
f"wrapper-v2: POST /decrypt returned truncated sample {i}"
|
||||
)
|
||||
out.append(data[offset:end])
|
||||
offset = end
|
||||
|
||||
if offset != len(data):
|
||||
raise IOError("wrapper-v2: POST /decrypt returned trailing bytes")
|
||||
return out
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
base_url: str = "http://127.0.0.1",
|
||||
get_credentials_func: CredentialsFunc | None = None,
|
||||
get_2fa_code: TwoFactorCodeFunc | None = None,
|
||||
) -> WrapperApi:
|
||||
client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(600.0, connect=30.0),
|
||||
)
|
||||
|
||||
base_url = base_url.rstrip("/")
|
||||
|
||||
me = await cls.get_me(client, base_url)
|
||||
if get_credentials_func is not None and me["auth"]["state"] == "logged_out":
|
||||
username, password = await _invoke(get_credentials_func)
|
||||
await cls.login(
|
||||
client,
|
||||
base_url,
|
||||
username,
|
||||
password,
|
||||
get_2fa_code,
|
||||
)
|
||||
me = await cls.get_me(client, base_url)
|
||||
|
||||
if me.get("auth", {}).get("state") == "logged_out":
|
||||
raise GamdlApiResponseError(
|
||||
"Wrapper is not authenticated. "
|
||||
"Provide get_credentials_func or log in via the wrapper.",
|
||||
)
|
||||
|
||||
return cls(base_url, client, me)
|
||||
|
||||
@staticmethod
|
||||
async def login(
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
get_2fa_code: TwoFactorCodeFunc | None = None,
|
||||
) -> None:
|
||||
base_url = base_url.rstrip("/")
|
||||
response = await client.post(
|
||||
f"{base_url}/login",
|
||||
json={"username": username, "password": password},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return
|
||||
|
||||
if response.status_code == 202:
|
||||
if get_2fa_code is None:
|
||||
raise GamdlApiResponseError(
|
||||
"Wrapper login requires 2FA; provide get_2fa_code",
|
||||
status_code=202,
|
||||
)
|
||||
code = await _invoke(get_2fa_code)
|
||||
tfa_response = await client.post(
|
||||
f"{base_url}/login/2fa",
|
||||
json={"code": code},
|
||||
)
|
||||
if tfa_response.is_error:
|
||||
raise GamdlApiResponseError(
|
||||
"Wrapper 2FA login failed",
|
||||
content=tfa_response.text,
|
||||
status_code=tfa_response.status_code,
|
||||
)
|
||||
return
|
||||
|
||||
raise GamdlApiResponseError(
|
||||
"Wrapper login failed",
|
||||
content=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_me(client: httpx.AsyncClient, base_url: str) -> dict:
|
||||
log = logger.bind(action="wrapper_get_me")
|
||||
|
||||
response = None
|
||||
|
||||
try:
|
||||
response = await client.get(f"{base_url}/me")
|
||||
response.raise_for_status()
|
||||
account_info = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching wrapper account info",
|
||||
content=getattr(response, "text", None),
|
||||
status_code=getattr(response, "status_code", None),
|
||||
)
|
||||
|
||||
log.debug("success", account_info=account_info)
|
||||
|
||||
return account_info
|
||||
|
||||
async def get_playback(self, media_id: str) -> dict:
|
||||
log = logger.bind(action="wrapper_get_playback", media_id=media_id)
|
||||
|
||||
response = None
|
||||
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url}/playback",
|
||||
params={"adam_id": media_id},
|
||||
)
|
||||
response.raise_for_status()
|
||||
playback = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching wrapper playback",
|
||||
content=getattr(response, "text", None),
|
||||
status_code=getattr(response, "status_code", None),
|
||||
)
|
||||
|
||||
log.debug("success", playback=playback)
|
||||
|
||||
return playback
|
||||
|
||||
async def decrypt(
|
||||
self,
|
||||
adam_id: str,
|
||||
skd_uri: str,
|
||||
ciphertexts: list[bytes],
|
||||
) -> list[bytes]:
|
||||
"""Decrypt one POST /decrypt batch; plaintexts match ciphertext order."""
|
||||
log = logger.bind(
|
||||
action="wrapper_decrypt",
|
||||
adam_id=adam_id,
|
||||
sample_count=len(ciphertexts),
|
||||
)
|
||||
|
||||
frame = self.build_decrypt_sample_frame(adam_id, skd_uri, ciphertexts)
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/decrypt",
|
||||
content=frame,
|
||||
headers={
|
||||
"content-type": "application/octet-stream",
|
||||
"accept": "application/octet-stream",
|
||||
},
|
||||
)
|
||||
if response.status_code == 401:
|
||||
raise IOError(
|
||||
"wrapper-v2: POST /decrypt returned 401 — log in with POST /login "
|
||||
"or restore a session on the daemon first"
|
||||
)
|
||||
if response.status_code == 503:
|
||||
raise IOError(
|
||||
"wrapper-v2: decrypt unavailable (503) — check daemon logs /health "
|
||||
"for playback_ready and Apple lib init"
|
||||
)
|
||||
if response.status_code != 200:
|
||||
detail = ""
|
||||
try:
|
||||
j = response.json()
|
||||
detail = (j.get("detail") or j.get("error") or str(j)) or ""
|
||||
except Exception:
|
||||
detail = (response.text or "")[:500]
|
||||
raise IOError(
|
||||
f"wrapper-v2: POST /decrypt failed HTTP {response.status_code}: {detail}"
|
||||
)
|
||||
|
||||
plaintexts = self.parse_decrypt_sample_frame(
|
||||
response.content,
|
||||
len(ciphertexts),
|
||||
)
|
||||
log.debug("success")
|
||||
return plaintexts
|
||||
+176
-177
@@ -1,39 +1,46 @@
|
||||
import asyncio
|
||||
import logging
|
||||
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, ItunesApi
|
||||
from ..api import AppleMusicApi
|
||||
from ..api.wrapper import WrapperApi
|
||||
from ..downloader import (
|
||||
AppleMusicBaseDownloader,
|
||||
AppleMusicDownloader,
|
||||
AppleMusicMusicVideoDownloader,
|
||||
AppleMusicSongDownloader,
|
||||
AppleMusicUploadedVideoDownloader,
|
||||
DownloadItem,
|
||||
DownloadMode,
|
||||
GamdlError,
|
||||
RemuxMode,
|
||||
GamdlDownloaderDependencyNotFoundError,
|
||||
GamdlDownloaderMediaFileExistsError,
|
||||
GamdlDownloaderSyncedLyricsOnlyError,
|
||||
)
|
||||
from ..interface import (
|
||||
AppleMusicBaseInterface,
|
||||
AppleMusicInterface,
|
||||
AppleMusicMusicVideoInterface,
|
||||
AppleMusicSongInterface,
|
||||
AppleMusicUploadedVideoInterface,
|
||||
SongCodec,
|
||||
GamdlInterfaceArtistMediaTypeError,
|
||||
GamdlInterfaceDecryptionNotAvailableError,
|
||||
GamdlInterfaceFlatFilterExcludedError,
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
GamdlInterfaceUrlParseError,
|
||||
)
|
||||
from .cli_config import CliConfig
|
||||
from .config_file import ConfigFile
|
||||
from .constants import X_NOT_IN_PATH
|
||||
from .utils import CustomLoggerFormatter, prompt_path
|
||||
from .database import Database
|
||||
from .interactive_prompts import InteractivePrompts
|
||||
from .utils import CustomOutputWriter, custom_structlog_formatter, prompt_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def make_sync(func):
|
||||
@@ -53,32 +60,40 @@ def make_sync(func):
|
||||
async def main(config: CliConfig):
|
||||
colorama.just_fix_windows_console()
|
||||
|
||||
root_logger = logging.getLogger(__name__.split(".")[0])
|
||||
root_logger.setLevel(config.log_level)
|
||||
root_logger.propagate = False
|
||||
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(CustomLoggerFormatter())
|
||||
root_logger.addHandler(stream_handler)
|
||||
log_output = CustomOutputWriter()
|
||||
|
||||
if config.log_file:
|
||||
file_handler = logging.FileHandler(config.log_file, encoding="utf-8")
|
||||
file_handler.setFormatter(CustomLoggerFormatter(use_colors=False))
|
||||
root_logger.addHandler(file_handler)
|
||||
log_output.add_file(config.log_file)
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.ExceptionPrettyPrinter(),
|
||||
custom_structlog_formatter,
|
||||
],
|
||||
logger_factory=structlog.PrintLoggerFactory(file=log_output),
|
||||
wrapper_class=structlog.make_filtering_bound_logger(config.log_level),
|
||||
)
|
||||
|
||||
logger.info(f"Starting Gamdl {__version__}")
|
||||
|
||||
interactive_prompts = InteractivePrompts(
|
||||
artist_auto_select=config.artist_auto_select,
|
||||
)
|
||||
|
||||
if config.use_wrapper:
|
||||
try:
|
||||
wrapper_api = await WrapperApi.create(
|
||||
base_url=config.wrapper_url,
|
||||
get_credentials_func=InteractivePrompts.get_wrapper_credentials,
|
||||
get_2fa_code=InteractivePrompts.get_wrapper_2fa_code,
|
||||
)
|
||||
apple_music_api = await AppleMusicApi.create_from_wrapper(
|
||||
wrapper_account_url=config.wrapper_account_url,
|
||||
wrapper_api=wrapper_api,
|
||||
language=config.language,
|
||||
)
|
||||
except ConnectError:
|
||||
logger.critical(
|
||||
"Could not connect to the wrapper account API. "
|
||||
"Make sure the wrapper is running and the URL is correct."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Error: {e}")
|
||||
return
|
||||
else:
|
||||
cookies_path = prompt_path(config.cookies_path)
|
||||
@@ -86,11 +101,7 @@ async def main(config: CliConfig):
|
||||
cookies_path=cookies_path,
|
||||
language=config.language,
|
||||
)
|
||||
|
||||
itunes_api = ItunesApi(
|
||||
apple_music_api.storefront,
|
||||
apple_music_api.language,
|
||||
)
|
||||
wrapper_api = None
|
||||
|
||||
if not apple_music_api.active_subscription:
|
||||
logger.critical(
|
||||
@@ -98,130 +109,110 @@ async def main(config: CliConfig):
|
||||
" anything"
|
||||
)
|
||||
return
|
||||
|
||||
if apple_music_api.account_restrictions:
|
||||
logger.warning(
|
||||
"Your account has content restrictions enabled, some content may not be"
|
||||
" downloadable"
|
||||
)
|
||||
|
||||
interface = AppleMusicInterface(
|
||||
apple_music_api,
|
||||
itunes_api,
|
||||
if (
|
||||
any(not codec.is_web 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, config.overwrite)
|
||||
flat_filter = database.flat_filter
|
||||
else:
|
||||
database = None
|
||||
flat_filter = None
|
||||
|
||||
base_interface = await AppleMusicBaseInterface.create(
|
||||
apple_music_api=apple_music_api,
|
||||
cover_format=config.cover_format,
|
||||
cover_size=config.cover_size,
|
||||
wvd_path=config.wvd_path,
|
||||
wrapper_api=wrapper_api,
|
||||
)
|
||||
|
||||
song_interface = AppleMusicSongInterface(
|
||||
base=base_interface,
|
||||
synced_lyrics_format=config.synced_lyrics_format,
|
||||
codec_priority=config.song_codec_piority,
|
||||
use_album_date=config.use_album_date,
|
||||
skip_stream_info=config.synced_lyrics_only,
|
||||
ask_codec_function=interactive_prompts.ask_song_codec,
|
||||
)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(
|
||||
base=base_interface,
|
||||
resolution=config.music_video_resolution,
|
||||
codec_priority=config.music_video_codec_priority,
|
||||
ask_video_codec_function=interactive_prompts.ask_music_video_video_codec_function,
|
||||
ask_audio_codec_function=interactive_prompts.ask_music_video_audio_codec_function,
|
||||
)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(
|
||||
base=base_interface,
|
||||
quality=config.uploaded_video_quality,
|
||||
ask_quality_function=interactive_prompts.ask_uploaded_video_quality_function,
|
||||
)
|
||||
|
||||
interface = AppleMusicInterface(
|
||||
song=song_interface,
|
||||
music_video=music_video_interface,
|
||||
uploaded_video=uploaded_video_interface,
|
||||
artist_select_media_type_function=interactive_prompts.ask_artist_media_type,
|
||||
artist_select_items_function=interactive_prompts.ask_artist_select_items,
|
||||
flat_filter_function=flat_filter,
|
||||
)
|
||||
song_interface = AppleMusicSongInterface(interface)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(interface)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
|
||||
|
||||
base_downloader = AppleMusicBaseDownloader(
|
||||
interface=interface,
|
||||
output_path=config.output_path,
|
||||
temp_path=config.temp_path,
|
||||
wvd_path=config.wvd_path,
|
||||
overwrite=config.overwrite,
|
||||
save_cover=config.save_cover,
|
||||
save_playlist=config.save_playlist,
|
||||
nm3u8dlre_path=config.nm3u8dlre_path,
|
||||
mp4decrypt_path=config.mp4decrypt_path,
|
||||
ffmpeg_path=config.ffmpeg_path,
|
||||
mp4box_path=config.mp4box_path,
|
||||
use_wrapper=config.use_wrapper,
|
||||
wrapper_decrypt_ip=config.wrapper_decrypt_ip,
|
||||
download_mode=config.download_mode,
|
||||
cover_format=config.cover_format,
|
||||
album_folder_template=config.album_folder_template,
|
||||
compilation_folder_template=config.compilation_folder_template,
|
||||
no_album_folder_template=config.no_album_folder_template,
|
||||
playlist_folder_template=config.playlist_folder_template,
|
||||
single_disc_file_template=config.single_disc_file_template,
|
||||
multi_disc_file_template=config.multi_disc_file_template,
|
||||
no_album_file_template=config.no_album_file_template,
|
||||
playlist_file_template=config.playlist_file_template,
|
||||
date_tag_template=config.date_tag_template,
|
||||
exclude_tags=config.exclude_tags,
|
||||
cover_size=config.cover_size,
|
||||
truncate=config.truncate,
|
||||
)
|
||||
|
||||
song_downloader = AppleMusicSongDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=song_interface,
|
||||
codec_priority=config.song_codec_piority,
|
||||
synced_lyrics_format=config.synced_lyrics_format,
|
||||
no_synced_lyrics=config.no_synced_lyrics,
|
||||
synced_lyrics_only=config.synced_lyrics_only,
|
||||
use_album_date=config.use_album_date,
|
||||
fetch_extra_tags=config.fetch_extra_tags,
|
||||
base=base_downloader,
|
||||
)
|
||||
music_video_downloader = AppleMusicMusicVideoDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=music_video_interface,
|
||||
codec_priority=config.music_video_codec_priority,
|
||||
remux_mode=config.music_video_remux_mode,
|
||||
base=base_downloader,
|
||||
remux_format=config.music_video_remux_format,
|
||||
resolution=config.music_video_resolution,
|
||||
)
|
||||
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=uploaded_video_interface,
|
||||
quality=config.uploaded_video_quality,
|
||||
base=base_downloader,
|
||||
)
|
||||
|
||||
downloader = AppleMusicDownloader(
|
||||
interface=interface,
|
||||
base_downloader=base_downloader,
|
||||
song_downloader=song_downloader,
|
||||
music_video_downloader=music_video_downloader,
|
||||
uploaded_video_downloader=uploaded_video_downloader,
|
||||
artist_auto_select=config.artist_auto_select,
|
||||
song=song_downloader,
|
||||
music_video=music_video_downloader,
|
||||
uploaded_video=uploaded_video_downloader,
|
||||
overwrite=config.overwrite,
|
||||
save_cover=config.save_cover,
|
||||
save_playlist=config.save_playlist,
|
||||
no_synced_lyrics=config.no_synced_lyrics,
|
||||
synced_lyrics_only=config.synced_lyrics_only,
|
||||
)
|
||||
|
||||
if not config.synced_lyrics_only:
|
||||
if (
|
||||
config.download_mode == DownloadMode.NM3U8DLRE
|
||||
and not base_downloader.full_nm3u8dlre_path
|
||||
):
|
||||
logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", config.nm3u8dlre_path))
|
||||
return
|
||||
|
||||
missing_music_video_paths = []
|
||||
|
||||
if not base_downloader.full_ffmpeg_path and (
|
||||
config.music_video_remux_mode == RemuxMode.FFMPEG
|
||||
or config.download_mode == DownloadMode.NM3U8DLRE
|
||||
):
|
||||
missing_music_video_paths.append(
|
||||
X_NOT_IN_PATH.format("ffmpeg", config.ffmpeg_path)
|
||||
)
|
||||
|
||||
if (
|
||||
not base_downloader.full_mp4box_path
|
||||
and config.music_video_remux_mode == RemuxMode.MP4BOX
|
||||
):
|
||||
missing_music_video_paths.append(
|
||||
X_NOT_IN_PATH.format("MP4Box", config.mp4box_path)
|
||||
)
|
||||
|
||||
if not base_downloader.full_mp4decrypt_path and (
|
||||
config.song_codec_piority
|
||||
not in (SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY)
|
||||
or config.music_video_remux_mode == RemuxMode.MP4BOX
|
||||
):
|
||||
missing_music_video_paths.append(
|
||||
X_NOT_IN_PATH.format("mp4decrypt", config.mp4decrypt_path)
|
||||
)
|
||||
|
||||
if missing_music_video_paths:
|
||||
logger.warning(
|
||||
"Music videos will not be downloaded due to missing dependencies:\n"
|
||||
+ "\n".join(missing_music_video_paths)
|
||||
)
|
||||
|
||||
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.read_urls_as_txt:
|
||||
urls_from_file = []
|
||||
for url in config.urls:
|
||||
@@ -239,68 +230,76 @@ async def main(config: CliConfig):
|
||||
|
||||
error_count = 0
|
||||
for url_index, url in enumerate(urls, 1):
|
||||
url_progress = click.style(f"[URL {url_index}/{len(urls)}]", dim=True)
|
||||
logger.info(url_progress + f' Processing "{url}"')
|
||||
download_queue = None
|
||||
url_log = logger.bind(action=f"URL {url_index:>3}/{len(urls):<3}")
|
||||
|
||||
url_log.info(f'Processing "{url}"')
|
||||
|
||||
try:
|
||||
url_info = downloader.get_url_info(url)
|
||||
if not url_info:
|
||||
logger.warning(
|
||||
url_progress + f' Could not parse "{url}", skipping.',
|
||||
)
|
||||
continue
|
||||
async for download_item in downloader.get_download_item_from_url(url):
|
||||
media_index = download_item.media.index + 1
|
||||
media_total = download_item.media.total or "-"
|
||||
|
||||
download_queue = await downloader.get_download_queue(url_info)
|
||||
if not download_queue:
|
||||
logger.warning(
|
||||
url_progress
|
||||
+ f' No downloadable media found for "{url}", skipping.',
|
||||
track_log = logger.bind(
|
||||
action=f"Track {media_index:>3}/{media_total:<3}"
|
||||
)
|
||||
continue
|
||||
except KeyboardInterrupt:
|
||||
exit(1)
|
||||
|
||||
media_title = (
|
||||
download_item.media.media_metadata["attributes"]["name"]
|
||||
if download_item.media.media_metadata
|
||||
and download_item.media.media_metadata.get("attributes", {}).get(
|
||||
"name"
|
||||
)
|
||||
else "Unknown Title"
|
||||
)
|
||||
media_type = (
|
||||
download_item.media.media_metadata["type"]
|
||||
if download_item.media.media_metadata
|
||||
else None
|
||||
)
|
||||
|
||||
if download_item.media.partial and media_type in {
|
||||
None,
|
||||
"songs",
|
||||
"library-songs",
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
"uploaded-videos",
|
||||
}:
|
||||
track_log.info(f'Downloading "{media_title}"')
|
||||
|
||||
try:
|
||||
await downloader.download(download_item)
|
||||
except (
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceDecryptionNotAvailableError,
|
||||
GamdlInterfaceArtistMediaTypeError,
|
||||
GamdlDownloaderSyncedLyricsOnlyError,
|
||||
GamdlDownloaderMediaFileExistsError,
|
||||
GamdlDownloaderDependencyNotFoundError,
|
||||
GamdlInterfaceFlatFilterExcludedError,
|
||||
) as e:
|
||||
track_log.warning(f'Skipping "{media_title}": {e}')
|
||||
continue
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
track_log.exception(f'Error downloading "{media_title}"')
|
||||
|
||||
if (
|
||||
database
|
||||
and download_item.media.media_metadata
|
||||
and download_item.final_path
|
||||
):
|
||||
database.add(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.final_path,
|
||||
)
|
||||
except GamdlInterfaceUrlParseError as e:
|
||||
url_log.error(f"{e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
url_log.exception(f'Error processing "{url}": {e}')
|
||||
error_count += 1
|
||||
logger.error(
|
||||
url_progress + f' Error processing "{url}"',
|
||||
exc_info=not config.no_exceptions,
|
||||
)
|
||||
|
||||
if not download_queue:
|
||||
continue
|
||||
|
||||
for download_index, download_item in enumerate(
|
||||
download_queue,
|
||||
1,
|
||||
):
|
||||
download_queue_progress = click.style(
|
||||
f"[Track {download_index}/{len(download_queue)}]",
|
||||
dim=True,
|
||||
)
|
||||
media_title = (
|
||||
download_item.media_metadata["attributes"]["name"]
|
||||
if isinstance(
|
||||
download_item,
|
||||
DownloadItem,
|
||||
)
|
||||
else "Unknown Title"
|
||||
)
|
||||
logger.info(download_queue_progress + f' Downloading "{media_title}"')
|
||||
|
||||
try:
|
||||
await downloader.download(download_item)
|
||||
except GamdlError as e:
|
||||
logger.warning(
|
||||
download_queue_progress + f' Skipping "{media_title}": {e}'
|
||||
)
|
||||
continue
|
||||
except KeyboardInterrupt:
|
||||
exit(1)
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
download_queue_progress + f' Error downloading "{media_title}"',
|
||||
exc_info=not config.no_exceptions,
|
||||
)
|
||||
|
||||
logger.info(f"Finished with {error_count} error(s)")
|
||||
|
||||
+191
-199
@@ -6,19 +6,22 @@ from typing import Annotated
|
||||
import click
|
||||
from dataclass_click import argument, option
|
||||
|
||||
from ..api import AppleMusicApi
|
||||
from ..api import AppleMusicApi, WrapperApi
|
||||
from ..downloader import (
|
||||
AppleMusicBaseDownloader,
|
||||
AppleMusicDownloader,
|
||||
AppleMusicMusicVideoDownloader,
|
||||
AppleMusicSongDownloader,
|
||||
AppleMusicUploadedVideoDownloader,
|
||||
ArtistAutoSelect,
|
||||
DownloadMode,
|
||||
RemuxFormatMusicVideo,
|
||||
RemuxMode,
|
||||
)
|
||||
from ..interface import (
|
||||
AppleMusicBaseInterface,
|
||||
AppleMusicInterface,
|
||||
AppleMusicMusicVideoInterface,
|
||||
AppleMusicSongInterface,
|
||||
AppleMusicUploadedVideoInterface,
|
||||
ArtistMediaType,
|
||||
CoverFormat,
|
||||
MusicVideoCodec,
|
||||
MusicVideoResolution,
|
||||
@@ -29,14 +32,19 @@ from ..interface import (
|
||||
from .utils import Csv
|
||||
|
||||
api_from_cookies_sig = inspect.signature(AppleMusicApi.create_from_netscape_cookies)
|
||||
api_from_wrapper_sig = inspect.signature(AppleMusicApi.create_from_wrapper)
|
||||
api_sig = inspect.signature(AppleMusicApi.__init__)
|
||||
wrapper_api_create_sig = inspect.signature(WrapperApi.create)
|
||||
api_create_sig = inspect.signature(AppleMusicApi.create)
|
||||
|
||||
base_interface_create_sig = inspect.signature(AppleMusicBaseInterface.create)
|
||||
song_interface_sig = inspect.signature(AppleMusicSongInterface.__init__)
|
||||
music_video_interface_sig = inspect.signature(AppleMusicMusicVideoInterface.__init__)
|
||||
uploaded_video_interface_sig = inspect.signature(
|
||||
AppleMusicUploadedVideoInterface.__init__
|
||||
)
|
||||
interface_create_sig = inspect.signature(AppleMusicInterface)
|
||||
|
||||
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
|
||||
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
|
||||
song_downloader_sig = inspect.signature(AppleMusicSongDownloader.__init__)
|
||||
uploaded_video_downloader_sig = inspect.signature(
|
||||
AppleMusicUploadedVideoDownloader.__init__
|
||||
)
|
||||
downloader_sig = inspect.signature(AppleMusicDownloader.__init__)
|
||||
|
||||
|
||||
@@ -105,6 +113,47 @@ class CliConfig:
|
||||
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,
|
||||
),
|
||||
]
|
||||
# Wrapper specific options
|
||||
wrapper_url: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--wrapper-url",
|
||||
help="Wrapper base URL",
|
||||
default=wrapper_api_create_sig.parameters["base_url"].default,
|
||||
),
|
||||
]
|
||||
# API specific options
|
||||
cookies_path: Annotated[
|
||||
str,
|
||||
@@ -121,31 +170,109 @@ class CliConfig:
|
||||
),
|
||||
),
|
||||
]
|
||||
wrapper_account_url: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--wrapper-account-url",
|
||||
help="Wrapper account URL",
|
||||
default=api_from_wrapper_sig.parameters["wrapper_account_url"].default,
|
||||
),
|
||||
]
|
||||
language: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--language",
|
||||
"-l",
|
||||
help="Metadata language",
|
||||
default=api_sig.parameters["language"].default,
|
||||
default=api_create_sig.parameters["language"].default,
|
||||
),
|
||||
]
|
||||
# Downloader specific options
|
||||
artist_auto_select: Annotated[
|
||||
ArtistAutoSelect | None,
|
||||
# Base Interface specific options
|
||||
cover_format: Annotated[
|
||||
CoverFormat,
|
||||
option(
|
||||
"--artist-auto-select",
|
||||
help="Automatically select artist content to download (only for artist URLs)",
|
||||
default=downloader_sig.parameters["artist_auto_select"].default,
|
||||
type=ArtistAutoSelect,
|
||||
"--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=True,
|
||||
dir_okay=False,
|
||||
writable=False,
|
||||
resolve_path=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
use_wrapper: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--use-wrapper",
|
||||
help="Use wrapper for account, playback, and decryption requests",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
# Song Interface Options
|
||||
synced_lyrics_format: Annotated[
|
||||
SyncedLyricsFormat,
|
||||
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
|
||||
@@ -178,45 +305,6 @@ class CliConfig:
|
||||
),
|
||||
),
|
||||
]
|
||||
wvd_path: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--wvd-path",
|
||||
help=".wvd file path",
|
||||
default=base_downloader_sig.parameters["wvd_path"].default,
|
||||
type=click.Path(
|
||||
file_okay=False,
|
||||
dir_okay=True,
|
||||
writable=True,
|
||||
resolve_path=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
overwrite: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--overwrite",
|
||||
help="Overwrite existing files",
|
||||
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,
|
||||
),
|
||||
]
|
||||
nm3u8dlre_path: Annotated[
|
||||
str,
|
||||
option(
|
||||
@@ -225,14 +313,6 @@ class CliConfig:
|
||||
default=base_downloader_sig.parameters["nm3u8dlre_path"].default,
|
||||
),
|
||||
]
|
||||
mp4decrypt_path: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--mp4decrypt-path",
|
||||
help="mp4decrypt executable path",
|
||||
default=base_downloader_sig.parameters["mp4decrypt_path"].default,
|
||||
),
|
||||
]
|
||||
ffmpeg_path: Annotated[
|
||||
str,
|
||||
option(
|
||||
@@ -241,30 +321,6 @@ class CliConfig:
|
||||
default=base_downloader_sig.parameters["ffmpeg_path"].default,
|
||||
),
|
||||
]
|
||||
mp4box_path: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--mp4box-path",
|
||||
help="MP4Box executable path",
|
||||
default=base_downloader_sig.parameters["mp4box_path"].default,
|
||||
),
|
||||
]
|
||||
use_wrapper: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--use-wrapper",
|
||||
help="Use wrapper for decrypting songs",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
wrapper_decrypt_ip: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--wrapper-decrypt-ip",
|
||||
help="IP address and port for wrapper decryption",
|
||||
default=base_downloader_sig.parameters["wrapper_decrypt_ip"].default,
|
||||
),
|
||||
]
|
||||
download_mode: Annotated[
|
||||
DownloadMode,
|
||||
option(
|
||||
@@ -274,15 +330,6 @@ class CliConfig:
|
||||
type=DownloadMode,
|
||||
),
|
||||
]
|
||||
cover_format: Annotated[
|
||||
CoverFormat,
|
||||
option(
|
||||
"--cover-format",
|
||||
help="Cover format",
|
||||
default=base_downloader_sig.parameters["cover_format"].default,
|
||||
type=CoverFormat,
|
||||
),
|
||||
]
|
||||
album_folder_template: Annotated[
|
||||
str,
|
||||
option(
|
||||
@@ -309,6 +356,14 @@ class CliConfig:
|
||||
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(
|
||||
@@ -358,14 +413,6 @@ class CliConfig:
|
||||
type=Csv(str),
|
||||
),
|
||||
]
|
||||
cover_size: Annotated[
|
||||
int,
|
||||
option(
|
||||
"--cover-size",
|
||||
help="Cover size in pixels",
|
||||
default=base_downloader_sig.parameters["cover_size"].default,
|
||||
),
|
||||
]
|
||||
truncate: Annotated[
|
||||
int,
|
||||
option(
|
||||
@@ -374,23 +421,40 @@ class CliConfig:
|
||||
default=base_downloader_sig.parameters["truncate"].default,
|
||||
),
|
||||
]
|
||||
# DownloaderSong specific options
|
||||
song_codec_piority: Annotated[
|
||||
list[SongCodec],
|
||||
# DownloaderMusicVideo specific options
|
||||
music_video_remux_format: Annotated[
|
||||
RemuxFormatMusicVideo,
|
||||
option(
|
||||
"--song-codec-priority",
|
||||
help="Comma-separated codec priority",
|
||||
default=song_downloader_sig.parameters["codec_priority"].default,
|
||||
type=Csv(SongCodec),
|
||||
"--music-video-remux-format",
|
||||
help="Music video remux format",
|
||||
default=music_video_downloader_sig.parameters["remux_format"].default,
|
||||
type=RemuxFormatMusicVideo,
|
||||
),
|
||||
]
|
||||
synced_lyrics_format: Annotated[
|
||||
SyncedLyricsFormat,
|
||||
# Downloader specific options
|
||||
overwrite: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--synced-lyrics-format",
|
||||
help="Synced lyrics format",
|
||||
default=song_downloader_sig.parameters["synced_lyrics_format"].default,
|
||||
type=SyncedLyricsFormat,
|
||||
"--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[
|
||||
@@ -409,75 +473,3 @@ class CliConfig:
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
use_album_date: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--use-album-date",
|
||||
help="Use album release date for songs",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
fetch_extra_tags: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--fetch-extra-tags",
|
||||
help="Fetch extra tags from preview (normalization and smooth playback)",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
# DownloaderMusicVideo specific options
|
||||
music_video_codec_priority: Annotated[
|
||||
list[MusicVideoCodec],
|
||||
option(
|
||||
"--music-video-codec-priority",
|
||||
help="Comma-separated codec priority",
|
||||
default=music_video_downloader_sig.parameters["codec_priority"].default,
|
||||
type=Csv(MusicVideoCodec),
|
||||
),
|
||||
]
|
||||
music_video_remux_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,
|
||||
),
|
||||
]
|
||||
music_video_resolution: Annotated[
|
||||
MusicVideoResolution,
|
||||
option(
|
||||
"--music-video-resolution",
|
||||
help="Max music video resolution",
|
||||
default=music_video_downloader_sig.parameters["resolution"].default,
|
||||
type=MusicVideoResolution,
|
||||
),
|
||||
]
|
||||
# DownloaderUploadedVideo specific options
|
||||
uploaded_video_quality: Annotated[
|
||||
UploadedVideoQuality,
|
||||
option(
|
||||
"--uploaded-video-quality",
|
||||
help="Post video quality",
|
||||
default=uploaded_video_downloader_sig.parameters["quality"].default,
|
||||
type=UploadedVideoQuality,
|
||||
),
|
||||
]
|
||||
no_config_file: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--no-config-file",
|
||||
"-n",
|
||||
help="Don't use a config file",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
overwrite: bool,
|
||||
):
|
||||
self.overwrite = overwrite
|
||||
|
||||
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 (
|
||||
"Registered in database"
|
||||
if Path(result).exists() and not self.overwrite
|
||||
else None
|
||||
)
|
||||
@@ -0,0 +1,248 @@
|
||||
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 get_wrapper_credentials() -> tuple[str, str]:
|
||||
username = await inquirer.text(
|
||||
message="Apple ID:",
|
||||
).execute_async()
|
||||
password = await inquirer.secret(
|
||||
message="Password:",
|
||||
).execute_async()
|
||||
return username, password
|
||||
|
||||
@staticmethod
|
||||
async def get_wrapper_2fa_code() -> str:
|
||||
return await inquirer.text(
|
||||
message="Two-factor authentication code:",
|
||||
).execute_async()
|
||||
|
||||
@staticmethod
|
||||
async def ask_song_codec(
|
||||
playlists: list[dict],
|
||||
) -> 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
|
||||
+50
-23
@@ -1,6 +1,9 @@
|
||||
import logging
|
||||
import atexit
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
@@ -38,31 +41,55 @@ class Csv(click.ParamType):
|
||||
return result
|
||||
|
||||
|
||||
class CustomLoggerFormatter(logging.Formatter):
|
||||
base_format = "[%(levelname)-8s %(asctime)s]"
|
||||
format_colors = {
|
||||
logging.DEBUG: dict(dim=True),
|
||||
logging.INFO: dict(fg="green"),
|
||||
logging.WARNING: dict(fg="yellow"),
|
||||
logging.ERROR: dict(fg="red"),
|
||||
logging.CRITICAL: dict(fg="red", bold=True),
|
||||
class CustomOutputWriter:
|
||||
def __init__(
|
||||
self,
|
||||
streams: list[Any] = [sys.stdout],
|
||||
):
|
||||
self.streams = streams
|
||||
|
||||
def add_file(self, path: str):
|
||||
file_stream = open(path, "a", encoding="utf-8")
|
||||
atexit.register(file_stream.close)
|
||||
self.streams.append(file_stream)
|
||||
|
||||
def write(self, message: str):
|
||||
for stream in self.streams:
|
||||
stream.write(message)
|
||||
|
||||
def flush(self):
|
||||
for stream in self.streams:
|
||||
stream.flush()
|
||||
|
||||
|
||||
def custom_structlog_formatter(
|
||||
logger: Any,
|
||||
name: str,
|
||||
event_dict: dict[str, Any],
|
||||
) -> str:
|
||||
level = event_dict.pop("level", "INFO").upper()
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
level_colors = {
|
||||
"DEBUG": "cyan",
|
||||
"INFO": "green",
|
||||
"WARNING": "yellow",
|
||||
"ERROR": "red",
|
||||
"CRITICAL": "red",
|
||||
}
|
||||
date_format = "%H:%M:%S"
|
||||
|
||||
def __init__(self, use_colors: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.use_colors = use_colors
|
||||
color = level_colors.get(level, "white")
|
||||
prefix = click.style(f"[{level:<8} {timestamp}]", fg=color)
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return logging.Formatter(
|
||||
(
|
||||
click.style(self.base_format, **self.format_colors.get(record.levelno))
|
||||
if self.use_colors
|
||||
else self.base_format
|
||||
)
|
||||
+ " %(message)s",
|
||||
datefmt=self.date_format,
|
||||
).format(record)
|
||||
action = event_dict.pop("action", None)
|
||||
if action:
|
||||
prefix += click.style(f" [{action}]", dim=True)
|
||||
|
||||
if level in {"INFO", "WARNING", "ERROR", "CRITICAL"}:
|
||||
message = event_dict.pop("event", "")
|
||||
return f"{prefix} {message}"
|
||||
else:
|
||||
return f"{prefix} {event_dict}"
|
||||
|
||||
|
||||
def prompt_path(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from .amdecrypt import decrypt_file_hex, decrypt_wrapper, write_decrypted_media
|
||||
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
|
||||
|
||||
+1989
-403
File diff suppressed because it is too large
Load Diff
@@ -1,122 +1,118 @@
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
import queue
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from pywidevine import Cdm, Device
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from yt_dlp.downloader.http import HttpFD
|
||||
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
|
||||
from .hardcoded_wvd import HARDCODED_WVD
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def _download_ytdlp_process(
|
||||
stream_url: str,
|
||||
download_path: str,
|
||||
silent: bool,
|
||||
result_queue,
|
||||
) -> None:
|
||||
try:
|
||||
common_args = {
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"noprogress": silent,
|
||||
}
|
||||
|
||||
if stream_url.split("?")[0].endswith(".m3u8"):
|
||||
with YoutubeDL(
|
||||
{
|
||||
**common_args,
|
||||
"outtmpl": download_path,
|
||||
"allow_unplayable_formats": True,
|
||||
"overwrites": True,
|
||||
"fixup": "never",
|
||||
"allowed_extractors": ["generic"],
|
||||
}
|
||||
) as ydl:
|
||||
ydl.download(stream_url)
|
||||
else:
|
||||
Path(download_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with YoutubeDL(common_args) as ydl:
|
||||
http_downloader = HttpFD(ydl, ydl.params)
|
||||
http_downloader.download(
|
||||
download_path,
|
||||
{
|
||||
"url": stream_url,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
result_queue.put(("error", repr(e), traceback.format_exc()))
|
||||
|
||||
|
||||
class AppleMusicBaseDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
interface: AppleMusicInterface,
|
||||
output_path: str = "./Apple Music",
|
||||
temp_path: str = ".",
|
||||
wvd_path: str = None,
|
||||
overwrite: bool = False,
|
||||
save_cover: bool = False,
|
||||
save_playlist: bool = False,
|
||||
nm3u8dlre_path: str = "N_m3u8DL-RE",
|
||||
mp4decrypt_path: str = "mp4decrypt",
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
mp4box_path: str = "MP4Box",
|
||||
use_wrapper: bool = False,
|
||||
wrapper_decrypt_ip: str = "127.0.0.1:10020",
|
||||
download_mode: DownloadMode = DownloadMode.YTDLP,
|
||||
cover_format: CoverFormat = CoverFormat.JPG,
|
||||
album_folder_template: str = "{album_artist}/{album}",
|
||||
compilation_folder_template: str = "Compilations/{album}",
|
||||
no_album_folder_template: str = "{artist}/Unknown Album",
|
||||
playlist_folder_template: str = "Playlists/{playlist_artist}",
|
||||
single_disc_file_template: str = "{track:02d} {title}",
|
||||
multi_disc_file_template: str = "{disc}-{track:02d} {title}",
|
||||
no_album_file_template: str = "{title}",
|
||||
playlist_file_template: str = "Playlists/{playlist_artist}/{playlist_title}",
|
||||
playlist_file_template: str = "{playlist_title}",
|
||||
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
|
||||
exclude_tags: list[str] = None,
|
||||
cover_size: int = 1200,
|
||||
truncate: int = None,
|
||||
silent: bool = False,
|
||||
):
|
||||
self.interface = interface
|
||||
self.output_path = output_path
|
||||
self.temp_path = temp_path
|
||||
self.wvd_path = wvd_path
|
||||
self.overwrite = overwrite
|
||||
self.save_cover = save_cover
|
||||
self.save_playlist = save_playlist
|
||||
self.nm3u8dlre_path = nm3u8dlre_path
|
||||
self.mp4decrypt_path = mp4decrypt_path
|
||||
self.ffmpeg_path = ffmpeg_path
|
||||
self.mp4box_path = mp4box_path
|
||||
self.use_wrapper = use_wrapper
|
||||
self.wrapper_decrypt_ip = wrapper_decrypt_ip
|
||||
self.download_mode = download_mode
|
||||
self.cover_format = cover_format
|
||||
self.album_folder_template = album_folder_template
|
||||
self.compilation_folder_template = compilation_folder_template
|
||||
self.no_album_folder_template = no_album_folder_template
|
||||
self.single_disc_file_template = single_disc_file_template
|
||||
self.multi_disc_file_template = multi_disc_file_template
|
||||
self.playlist_folder_template = playlist_folder_template
|
||||
self.no_album_file_template = no_album_file_template
|
||||
self.playlist_file_template = playlist_file_template
|
||||
self.date_tag_template = date_tag_template
|
||||
self.exclude_tags = exclude_tags
|
||||
self.cover_size = cover_size
|
||||
self.truncate = truncate
|
||||
self.silent = silent
|
||||
self.initialize()
|
||||
|
||||
def initialize(self):
|
||||
self._initialize_binary_paths()
|
||||
self._initialize_cdm()
|
||||
|
||||
def _initialize_binary_paths(self):
|
||||
log = logger.bind(action="initialize_binary_paths")
|
||||
|
||||
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
|
||||
self.full_mp4decrypt_path = shutil.which(self.mp4decrypt_path)
|
||||
self.full_ffmpeg_path = shutil.which(self.ffmpeg_path)
|
||||
self.full_mp4box_path = shutil.which(self.mp4box_path)
|
||||
|
||||
def _initialize_cdm(self):
|
||||
if self.wvd_path:
|
||||
self.cdm = Cdm.from_device(Device.load(self.wvd_path))
|
||||
else:
|
||||
self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
|
||||
self.cdm.MAX_NUM_OF_SESSIONS = float("inf")
|
||||
|
||||
def get_random_uuid(self) -> str:
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
def is_media_streamable(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
) -> bool:
|
||||
return bool(media_metadata["attributes"].get("playParams"))
|
||||
|
||||
def get_playlist_tags(
|
||||
self,
|
||||
playlist_metadata: dict,
|
||||
media_metadata: dict,
|
||||
) -> PlaylistTags:
|
||||
playlist_track = (
|
||||
playlist_metadata["relationships"]["tracks"]["data"].index(media_metadata)
|
||||
+ 1
|
||||
)
|
||||
|
||||
return PlaylistTags(
|
||||
playlist_artist=playlist_metadata["attributes"].get(
|
||||
"curatorName", "Unknown"
|
||||
),
|
||||
playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
|
||||
playlist_title=playlist_metadata["attributes"]["name"],
|
||||
playlist_track=playlist_track,
|
||||
log = log.debug(
|
||||
"success",
|
||||
full_nm3u8dlre_path=self.full_nm3u8dlre_path,
|
||||
full_ffmpeg_path=self.full_ffmpeg_path,
|
||||
)
|
||||
|
||||
def get_temp_path(
|
||||
@@ -126,13 +122,19 @@ class AppleMusicBaseDownloader:
|
||||
file_tag: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(
|
||||
log = logger.bind(action="get_temp_path")
|
||||
|
||||
temp_path = str(
|
||||
Path(self.temp_path)
|
||||
/ TEMP_PATH_TEMPLATE.format(folder_tag)
|
||||
/ (f"{media_id}_{file_tag}" + file_extension)
|
||||
)
|
||||
|
||||
def sanitize_string(
|
||||
log.debug("success", temp_path=temp_path)
|
||||
|
||||
return temp_path
|
||||
|
||||
def _sanitize_string(
|
||||
self,
|
||||
dirty_string: str,
|
||||
file_ext: str = None,
|
||||
@@ -160,6 +162,8 @@ class AppleMusicBaseDownloader:
|
||||
file_extension: str,
|
||||
playlist_tags: PlaylistTags | None,
|
||||
) -> str:
|
||||
log = logger.bind(action="get_final_path")
|
||||
|
||||
if tags.album:
|
||||
template_folder_parts = (
|
||||
self.compilation_folder_template.split("/")
|
||||
@@ -197,7 +201,7 @@ class AppleMusicBaseDownloader:
|
||||
disc_total=(tags.disc_total, ""),
|
||||
media_type=(tags.media_type, "Unknown Media Type"),
|
||||
playlist_artist=(
|
||||
(playlist_tags.playlist_artist if playlist_tags else None),
|
||||
(playlist_tags.artist if playlist_tags else None),
|
||||
"Unknown Playlist Artist",
|
||||
),
|
||||
playlist_id=(
|
||||
@@ -205,11 +209,11 @@ class AppleMusicBaseDownloader:
|
||||
"Unknown Playlist ID",
|
||||
),
|
||||
playlist_title=(
|
||||
(playlist_tags.playlist_title if playlist_tags else None),
|
||||
(playlist_tags.title if playlist_tags else None),
|
||||
"Unknown Playlist Title",
|
||||
),
|
||||
playlist_track=(
|
||||
(playlist_tags.playlist_track if playlist_tags else None),
|
||||
(playlist_tags.track if playlist_tags else None),
|
||||
"",
|
||||
),
|
||||
title=(tags.title, "Unknown Title"),
|
||||
@@ -217,44 +221,70 @@ class AppleMusicBaseDownloader:
|
||||
track=(tags.track, ""),
|
||||
track_total=(tags.track_total, ""),
|
||||
)
|
||||
sanitized_formatted_part = self.sanitize_string(
|
||||
sanitized_formatted_part = self._sanitize_string(
|
||||
formatted_part,
|
||||
file_extension if not is_folder else None,
|
||||
)
|
||||
formatted_parts.append(sanitized_formatted_part)
|
||||
|
||||
return str(Path(self.output_path, *formatted_parts))
|
||||
final_path = str(Path(self.output_path, *formatted_parts))
|
||||
|
||||
log.debug("success", final_path=final_path)
|
||||
|
||||
return final_path
|
||||
|
||||
async def download_stream(self, stream_url: str, download_path: str):
|
||||
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,
|
||||
log = logger.bind(
|
||||
action="download_stream", stream_url=stream_url, download_path=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)
|
||||
if self.download_mode == DownloadMode.YTDLP or not stream_url.split("?")[
|
||||
0
|
||||
].endswith(".m3u8"):
|
||||
await self._download_ytdlp_async(stream_url, download_path)
|
||||
|
||||
async def download_nm3u8dlre(self, stream_url: str, download_path: str):
|
||||
elif self.download_mode == DownloadMode.NM3U8DLRE:
|
||||
await self._download_nm3u8dlre(stream_url, download_path)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
async def _download_ytdlp_async(self, stream_url: str, download_path: str) -> None:
|
||||
ctx = multiprocessing.get_context()
|
||||
result_queue = ctx.Queue()
|
||||
process = ctx.Process(
|
||||
target=_download_ytdlp_process,
|
||||
args=(stream_url, download_path, self.silent, result_queue),
|
||||
)
|
||||
process.start()
|
||||
|
||||
try:
|
||||
while process.is_alive():
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
process.join()
|
||||
|
||||
try:
|
||||
status, error_repr, error_traceback = result_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
status = None
|
||||
|
||||
if status == "error":
|
||||
raise RuntimeError(
|
||||
f"yt-dlp failed: {error_repr}\n{error_traceback}"
|
||||
) from None
|
||||
|
||||
if process.exitcode != 0:
|
||||
raise RuntimeError(f"yt-dlp exited with code {process.exitcode}")
|
||||
finally:
|
||||
if process.is_alive():
|
||||
process.terminate()
|
||||
await asyncio.to_thread(process.join, 5)
|
||||
if process.is_alive():
|
||||
process.kill()
|
||||
await asyncio.to_thread(process.join)
|
||||
process.close()
|
||||
|
||||
async def _download_nm3u8dlre(self, stream_url: str, download_path: str):
|
||||
download_path_obj = Path(download_path)
|
||||
|
||||
download_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -278,11 +308,12 @@ class AppleMusicBaseDownloader:
|
||||
|
||||
async def apply_tags(
|
||||
self,
|
||||
media_path: Path,
|
||||
media_path: str,
|
||||
tags: MediaTags,
|
||||
cover_bytes: bytes | None,
|
||||
extra_tags: dict | None = None,
|
||||
):
|
||||
log = logger.bind(action="apply_tags", media_path=media_path)
|
||||
|
||||
exclude_tags = self.exclude_tags or []
|
||||
|
||||
filtered_tags = MediaTags(
|
||||
@@ -297,21 +328,21 @@ class AppleMusicBaseDownloader:
|
||||
skip_tagging = "all" in exclude_tags
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.apply_mp4_tags,
|
||||
self._apply_mp4_tags,
|
||||
media_path,
|
||||
mp4_tags,
|
||||
cover_bytes,
|
||||
skip_tagging,
|
||||
extra_tags,
|
||||
)
|
||||
|
||||
def apply_mp4_tags(
|
||||
log.debug("success")
|
||||
|
||||
def _apply_mp4_tags(
|
||||
self,
|
||||
media_path: Path,
|
||||
media_path: str,
|
||||
tags: dict,
|
||||
cover_bytes: bytes | None,
|
||||
skip_tagging: bool,
|
||||
extra_tags: dict | None,
|
||||
):
|
||||
mp4 = MP4(media_path)
|
||||
mp4.clear()
|
||||
@@ -323,14 +354,12 @@ class AppleMusicBaseDownloader:
|
||||
data=cover_bytes,
|
||||
imageformat=(
|
||||
MP4Cover.FORMAT_JPEG
|
||||
if self.cover_format == CoverFormat.JPG
|
||||
if self.interface.base.cover_format == CoverFormat.JPG
|
||||
else MP4Cover.FORMAT_PNG
|
||||
),
|
||||
)
|
||||
]
|
||||
mp4.update(tags)
|
||||
if extra_tags:
|
||||
mp4.update(extra_tags)
|
||||
|
||||
mp4.save()
|
||||
|
||||
@@ -347,82 +376,41 @@ class AppleMusicBaseDownloader:
|
||||
data=cover_bytes,
|
||||
imageformat=(
|
||||
MP4Cover.FORMAT_JPEG
|
||||
if self.cover_format == CoverFormat.JPG
|
||||
if self.interface.base.cover_format == CoverFormat.JPG
|
||||
else MP4Cover.FORMAT_PNG
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
def move_to_final_path(self, stage_path: str, final_path: str) -> None:
|
||||
Path(final_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(stage_path, final_path)
|
||||
|
||||
def write_cover_image(
|
||||
self,
|
||||
cover_bytes: bytes,
|
||||
cover_path: str,
|
||||
) -> None:
|
||||
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(cover_path).write_bytes(cover_bytes)
|
||||
|
||||
def get_playlist_file_path(
|
||||
self,
|
||||
tags: PlaylistTags,
|
||||
) -> str:
|
||||
log = logger.bind(action="get_playlist_file_path")
|
||||
|
||||
template_folder_parts = self.playlist_folder_template.split("/")
|
||||
template_file_parts = self.playlist_file_template.split("/")
|
||||
template_parts = template_folder_parts + template_file_parts
|
||||
formatted_parts = []
|
||||
|
||||
for i, part in enumerate(template_file_parts):
|
||||
is_folder = i < len(template_file_parts) - 1
|
||||
for i, part in enumerate(template_parts):
|
||||
is_folder = i < len(template_parts) - 1
|
||||
formatted_part = CustomStringFormatter().format(
|
||||
part,
|
||||
playlist_artist=(tags.playlist_artist, "Unknown Playlist Artist"),
|
||||
playlist_artist=(tags.artist, "Unknown Playlist Artist"),
|
||||
playlist_id=(tags.playlist_id, "Unknown Playlist ID"),
|
||||
playlist_title=(tags.playlist_title, "Unknown Playlist Title"),
|
||||
playlist_track=(tags.playlist_track, ""),
|
||||
playlist_title=(tags.title, "Unknown Playlist Title"),
|
||||
playlist_track=(tags.track, ""),
|
||||
)
|
||||
file_ext = None if is_folder else ".m3u8"
|
||||
sanitized_formatted_part = self.sanitize_string(
|
||||
file_ext = None if is_folder else ".m3u"
|
||||
sanitized_formatted_part = self._sanitize_string(
|
||||
formatted_part,
|
||||
file_ext,
|
||||
)
|
||||
formatted_parts.append(sanitized_formatted_part)
|
||||
|
||||
return str(Path(self.output_path, *formatted_parts))
|
||||
final_path = str(Path(self.output_path, *formatted_parts))
|
||||
|
||||
def update_playlist_file(
|
||||
self,
|
||||
playlist_file_path: str,
|
||||
final_path: str,
|
||||
playlist_track: int,
|
||||
) -> None:
|
||||
playlist_file_path_obj = Path(playlist_file_path)
|
||||
final_path_obj = Path(final_path)
|
||||
output_dir_obj = Path(self.output_path)
|
||||
log.debug("success", playlist_file_path=final_path)
|
||||
|
||||
playlist_file_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
playlist_file_path_parent_parts_len = len(playlist_file_path_obj.parent.parts)
|
||||
output_path_parts_len = len(output_dir_obj.parts)
|
||||
|
||||
final_path_relative = Path(
|
||||
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
|
||||
*final_path_obj.parts[output_path_parts_len:],
|
||||
)
|
||||
playlist_file_lines = (
|
||||
playlist_file_path_obj.open("r", encoding="utf8").readlines()
|
||||
if playlist_file_path_obj.exists()
|
||||
else []
|
||||
)
|
||||
if len(playlist_file_lines) < playlist_track:
|
||||
playlist_file_lines.extend(
|
||||
"\n" for _ in range(playlist_track - len(playlist_file_lines))
|
||||
)
|
||||
|
||||
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
|
||||
with playlist_file_path_obj.open("w", encoding="utf8") as playlist_file:
|
||||
playlist_file.writelines(playlist_file_lines)
|
||||
|
||||
def cleanup_temp(self, random_uuid: str) -> None:
|
||||
temp_folder = Path(self.temp_path) / TEMP_PATH_TEMPLATE.format(random_uuid)
|
||||
if temp_folder.exists():
|
||||
shutil.rmtree(temp_folder)
|
||||
return final_path
|
||||
@@ -1,46 +1,3 @@
|
||||
import re
|
||||
|
||||
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"}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
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")"
|
||||
)
|
||||
|
||||
+199
-562
@@ -1,617 +1,254 @@
|
||||
import asyncio
|
||||
import typing
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
import structlog
|
||||
|
||||
from ..api.exceptions import ApiError
|
||||
from ..interface import AppleMusicInterface
|
||||
from ..utils import safe_gather
|
||||
from .constants import (
|
||||
ALBUM_MEDIA_TYPE,
|
||||
ARTIST_MEDIA_TYPE,
|
||||
MUSIC_VIDEO_MEDIA_TYPE,
|
||||
PLAYLIST_MEDIA_TYPE,
|
||||
SONG_MEDIA_TYPE,
|
||||
UPLOADED_VIDEO_MEDIA_TYPE,
|
||||
VALID_URL_PATTERN,
|
||||
)
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .downloader_music_video import AppleMusicMusicVideoDownloader
|
||||
from .downloader_song import AppleMusicSongDownloader
|
||||
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
from .enums import ArtistAutoSelect, DownloadMode, RemuxMode
|
||||
from ..interface.types import AppleMusicMedia
|
||||
from .constants import TEMP_PATH_TEMPLATE
|
||||
from .enums import DownloadMode
|
||||
from .exceptions import (
|
||||
ExecutableNotFound,
|
||||
FormatNotAvailable,
|
||||
MediaFileExists,
|
||||
NotStreamable,
|
||||
SyncedLyricsOnly,
|
||||
UnsupportedMediaType,
|
||||
GamdlDownloaderDependencyNotFoundError,
|
||||
GamdlDownloaderMediaFileExistsError,
|
||||
GamdlDownloaderSyncedLyricsOnlyError,
|
||||
)
|
||||
from .types import DownloadItem, UrlInfo
|
||||
from .music_video import AppleMusicMusicVideoDownloader
|
||||
from .song import AppleMusicSongDownloader
|
||||
from .types import DownloadItem
|
||||
from .uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
interface: AppleMusicInterface,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
song_downloader: AppleMusicSongDownloader,
|
||||
music_video_downloader: AppleMusicMusicVideoDownloader,
|
||||
uploaded_video_downloader: AppleMusicUploadedVideoDownloader,
|
||||
artist_auto_select: ArtistAutoSelect | None = None,
|
||||
skip_music_videos: bool = False,
|
||||
song: AppleMusicSongDownloader,
|
||||
music_video: AppleMusicMusicVideoDownloader,
|
||||
uploaded_video: AppleMusicUploadedVideoDownloader,
|
||||
overwrite: bool = False,
|
||||
save_cover: bool = False,
|
||||
save_playlist: bool = False,
|
||||
no_synced_lyrics: bool = False,
|
||||
synced_lyrics_only: bool = False,
|
||||
skip_cleanup: bool = False,
|
||||
skip_processing: bool = False,
|
||||
flat_filter: typing.Callable = None,
|
||||
):
|
||||
self.interface = interface
|
||||
self.base_downloader = base_downloader
|
||||
self.song_downloader = song_downloader
|
||||
self.music_video_downloader = music_video_downloader
|
||||
self.uploaded_video_downloader = uploaded_video_downloader
|
||||
self.artist_auto_select = artist_auto_select
|
||||
self.skip_music_videos = skip_music_videos
|
||||
self.song = song
|
||||
self.music_video = music_video
|
||||
self.uploaded_video = uploaded_video
|
||||
self.overwrite = overwrite
|
||||
self.save_cover = save_cover
|
||||
self.save_playlist = save_playlist
|
||||
self.no_synced_lyrics = no_synced_lyrics
|
||||
self.synced_lyrics_only = synced_lyrics_only
|
||||
self.skip_cleanup = skip_cleanup
|
||||
self.skip_processing = skip_processing
|
||||
self.flat_filter = flat_filter
|
||||
|
||||
async def get_single_download_item(
|
||||
self.base = song.base
|
||||
|
||||
async def get_download_item_from_url(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
url: str,
|
||||
) -> AsyncGenerator[DownloadItem, None]:
|
||||
async for media in self.base.interface.get_media_from_url(url):
|
||||
yield await self.parse_download_item(media)
|
||||
|
||||
async def parse_download_item(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> DownloadItem:
|
||||
if self.flat_filter:
|
||||
flat_filter_result = self.flat_filter(media_metadata)
|
||||
if asyncio.iscoroutine(flat_filter_result):
|
||||
flat_filter_result = await flat_filter_result
|
||||
if media.error:
|
||||
return DownloadItem(media)
|
||||
|
||||
if flat_filter_result:
|
||||
return DownloadItem(
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
flat_filter_result=flat_filter_result,
|
||||
)
|
||||
if media.partial:
|
||||
return DownloadItem(media)
|
||||
|
||||
return await self.get_single_download_item_no_filter(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
elif media.media_metadata["type"] in {"songs", "library-songs"}:
|
||||
return await self.song.get_download_item(media)
|
||||
|
||||
async def get_single_download_item_no_filter(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
try:
|
||||
if not self.base_downloader.is_media_streamable(
|
||||
media_metadata,
|
||||
):
|
||||
raise NotStreamable(media_metadata["id"])
|
||||
|
||||
if media_metadata["type"] in SONG_MEDIA_TYPE:
|
||||
if not self.song_downloader:
|
||||
raise UnsupportedMediaType(media_metadata["type"])
|
||||
|
||||
download_item = await self.song_downloader.get_download_item(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
|
||||
if media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
if not self.music_video_downloader:
|
||||
raise UnsupportedMediaType(media_metadata["type"])
|
||||
|
||||
download_item = await self.music_video_downloader.get_download_item(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
|
||||
if media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
|
||||
if not self.uploaded_video_downloader:
|
||||
raise UnsupportedMediaType(media_metadata["type"])
|
||||
|
||||
download_item = await self.uploaded_video_downloader.get_download_item(
|
||||
media_metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
download_item = DownloadItem(
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
error=e,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def get_collection_download_items(
|
||||
self,
|
||||
collection_metadata: dict,
|
||||
) -> list[DownloadItem]:
|
||||
tracks_metadata = collection_metadata["relationships"]["tracks"]["data"]
|
||||
async for extended_data in self.interface.apple_music_api.extend_api_data(
|
||||
collection_metadata["relationships"]["tracks"],
|
||||
):
|
||||
tracks_metadata.extend(extended_data["data"])
|
||||
|
||||
tasks = [
|
||||
self.get_single_download_item(
|
||||
media_metadata,
|
||||
(
|
||||
collection_metadata
|
||||
if collection_metadata["type"] in PLAYLIST_MEDIA_TYPE
|
||||
else None
|
||||
),
|
||||
)
|
||||
for media_metadata in tracks_metadata
|
||||
]
|
||||
|
||||
download_items = await safe_gather(*tasks)
|
||||
return download_items
|
||||
|
||||
async def get_artist_download_items(
|
||||
self,
|
||||
artist_metadata: dict,
|
||||
) -> list[DownloadItem]:
|
||||
if not self.artist_auto_select:
|
||||
available_choices = []
|
||||
for artist_auto_select_option in list(ArtistAutoSelect):
|
||||
relation_key, type_key = artist_auto_select_option.path_key
|
||||
available_choices.append(
|
||||
Choice(
|
||||
name=str(artist_auto_select_option),
|
||||
value=(artist_auto_select_option,),
|
||||
),
|
||||
)
|
||||
|
||||
(artist_auto_select,) = 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()
|
||||
else:
|
||||
artist_auto_select = self.artist_auto_select
|
||||
|
||||
relation_key, type_key = artist_auto_select.path_key
|
||||
async for extended_data in self.interface.apple_music_api.extend_api_data(
|
||||
artist_metadata[relation_key][type_key],
|
||||
):
|
||||
artist_metadata[relation_key][type_key]["data"].extend(extended_data["data"])
|
||||
|
||||
selected_items = artist_metadata[relation_key][type_key]["data"]
|
||||
select_all = self.artist_auto_select is not None
|
||||
|
||||
if artist_auto_select in {
|
||||
ArtistAutoSelect.MAIN_ALBUMS,
|
||||
ArtistAutoSelect.COMPILATION_ALBUMS,
|
||||
ArtistAutoSelect.LIVE_ALBUMS,
|
||||
ArtistAutoSelect.SINGLES_EPS,
|
||||
ArtistAutoSelect.ALL_ALBUMS,
|
||||
elif media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
}:
|
||||
return await self.get_artist_albums_download_items(
|
||||
selected_items,
|
||||
select_all,
|
||||
)
|
||||
elif artist_auto_select == ArtistAutoSelect.TOP_SONGS:
|
||||
return await self.get_artist_songs_download_items(
|
||||
selected_items,
|
||||
select_all,
|
||||
)
|
||||
elif artist_auto_select == ArtistAutoSelect.MUSIC_VIDEOS:
|
||||
return await self.get_artist_music_videos_download_items(
|
||||
selected_items,
|
||||
select_all,
|
||||
)
|
||||
return await self.music_video.get_download_item(media)
|
||||
|
||||
async def get_artist_albums_download_items(
|
||||
self,
|
||||
albums_metadata: list[dict],
|
||||
select_all: bool = False,
|
||||
) -> list[DownloadItem]:
|
||||
if not select_all:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
f'{album["attributes"]["trackCount"]:03d}',
|
||||
f'{album["attributes"]["releaseDate"]:<10}',
|
||||
f'{album["attributes"].get("contentRating", "None").title():<8}',
|
||||
f'{album["attributes"]["name"]}',
|
||||
]
|
||||
),
|
||||
value=album,
|
||||
)
|
||||
for album in albums_metadata
|
||||
if album.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
else:
|
||||
selected = albums_metadata
|
||||
elif media.media_metadata["type"] in {"uploaded-videos"}:
|
||||
return await self.uploaded_video.get_download_item(media)
|
||||
|
||||
download_items = []
|
||||
|
||||
album_tasks = [
|
||||
self.interface.apple_music_api.get_album(album_metadata["id"])
|
||||
for album_metadata in selected
|
||||
]
|
||||
album_responses = await safe_gather(*album_tasks)
|
||||
|
||||
track_tasks = [
|
||||
self.get_collection_download_items(album_response["data"][0])
|
||||
for album_response in album_responses
|
||||
]
|
||||
track_results = await safe_gather(*track_tasks)
|
||||
|
||||
for track_result in track_results:
|
||||
download_items.extend(track_result)
|
||||
|
||||
return download_items
|
||||
|
||||
async def get_artist_music_videos_download_items(
|
||||
self,
|
||||
music_videos_metadata: list[dict],
|
||||
select_all: bool = False,
|
||||
) -> list[DownloadItem]:
|
||||
if not select_all:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(
|
||||
music_video["attributes"]["durationInMillis"]
|
||||
),
|
||||
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
|
||||
music_video["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=music_video,
|
||||
)
|
||||
for music_video in music_videos_metadata
|
||||
if music_video.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which music videos to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
else:
|
||||
selected = music_videos_metadata
|
||||
|
||||
music_video_tasks = [
|
||||
self.get_single_download_item(
|
||||
music_video_metadata,
|
||||
)
|
||||
for music_video_metadata in selected
|
||||
]
|
||||
download_items = await safe_gather(*music_video_tasks)
|
||||
|
||||
return download_items
|
||||
|
||||
async def get_artist_songs_download_items(
|
||||
self,
|
||||
songs_metadata: list[dict],
|
||||
select_all: bool = False,
|
||||
) -> list[DownloadItem]:
|
||||
if not select_all:
|
||||
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_metadata
|
||||
if song.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which songs to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
else:
|
||||
selected = songs_metadata
|
||||
|
||||
song_tasks = [
|
||||
self.get_single_download_item(
|
||||
song_metadata,
|
||||
)
|
||||
for song_metadata in selected
|
||||
]
|
||||
download_items = await safe_gather(*song_tasks)
|
||||
|
||||
return download_items
|
||||
|
||||
def millis_to_min_sec(self, millis) -> str:
|
||||
minutes, seconds = divmod(millis // 1000, 60)
|
||||
return f"{minutes:02}:{seconds:02}"
|
||||
|
||||
def get_url_info(self, url: str) -> UrlInfo | None:
|
||||
match = VALID_URL_PATTERN.match(url)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
return UrlInfo(
|
||||
**match.groupdict(),
|
||||
)
|
||||
|
||||
async def get_download_queue(
|
||||
self,
|
||||
url_info: UrlInfo,
|
||||
) -> list[DownloadItem] | None:
|
||||
return await self._get_download_queue(
|
||||
"song" if url_info.sub_id else url_info.type or url_info.library_type,
|
||||
url_info.sub_id or url_info.id or url_info.library_id,
|
||||
url_info.library_id is not None,
|
||||
)
|
||||
|
||||
async def _get_download_queue(
|
||||
self,
|
||||
url_type: str,
|
||||
id: str,
|
||||
is_library: bool,
|
||||
) -> list[DownloadItem] | None:
|
||||
download_items = []
|
||||
|
||||
if url_type in ARTIST_MEDIA_TYPE:
|
||||
try:
|
||||
artist_response = await self.interface.apple_music_api.get_artist(
|
||||
id,
|
||||
)
|
||||
except ApiError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise e
|
||||
|
||||
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:
|
||||
try:
|
||||
song_respose = await self.interface.apple_music_api.get_song(id)
|
||||
except ApiError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise e
|
||||
|
||||
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:
|
||||
try:
|
||||
if is_library:
|
||||
album_response = (
|
||||
await self.interface.apple_music_api.get_library_album(id)
|
||||
)
|
||||
else:
|
||||
album_response = await self.interface.apple_music_api.get_album(id)
|
||||
except ApiError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise e
|
||||
|
||||
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:
|
||||
try:
|
||||
if is_library:
|
||||
playlist_response = (
|
||||
await self.interface.apple_music_api.get_library_playlist(id)
|
||||
)
|
||||
else:
|
||||
playlist_response = (
|
||||
await self.interface.apple_music_api.get_playlist(id)
|
||||
)
|
||||
except ApiError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise e
|
||||
|
||||
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:
|
||||
try:
|
||||
music_video_response = (
|
||||
await self.interface.apple_music_api.get_music_video(id)
|
||||
)
|
||||
except ApiError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise e
|
||||
|
||||
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:
|
||||
try:
|
||||
uploaded_video = (
|
||||
await self.interface.apple_music_api.get_uploaded_video(id)
|
||||
)
|
||||
except ApiError as e:
|
||||
if e.status_code == 404:
|
||||
return None
|
||||
raise e
|
||||
|
||||
if uploaded_video is None:
|
||||
return None
|
||||
|
||||
download_items.append(
|
||||
await self.get_single_download_item(uploaded_video["data"][0])
|
||||
)
|
||||
|
||||
return download_items
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> DownloadItem:
|
||||
async def download(self, item: DownloadItem) -> None:
|
||||
try:
|
||||
if download_item.flat_filter_result:
|
||||
download_item = await self.get_single_download_item_no_filter(
|
||||
download_item.media_metadata,
|
||||
download_item.playlist_metadata,
|
||||
)
|
||||
if item.media.error:
|
||||
raise item.media.error
|
||||
|
||||
if download_item.error:
|
||||
raise download_item.error
|
||||
if item.media.partial:
|
||||
return
|
||||
|
||||
await self._initial_processing(download_item)
|
||||
await self._download(download_item)
|
||||
await self._final_processing(download_item)
|
||||
|
||||
return download_item
|
||||
await self._initial_processing(item)
|
||||
await self._download(item)
|
||||
await self._final_processing(item)
|
||||
finally:
|
||||
if isinstance(download_item, DownloadItem) and not self.skip_processing:
|
||||
self.base_downloader.cleanup_temp(download_item.random_uuid)
|
||||
if not self.skip_cleanup:
|
||||
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
|
||||
):
|
||||
raise SyncedLyricsOnly()
|
||||
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:
|
||||
return
|
||||
playlist_file_path_obj = Path(playlist_file_path)
|
||||
final_path_obj = Path(final_path)
|
||||
output_dir_obj = Path(self.base.output_path)
|
||||
|
||||
if (
|
||||
Path(download_item.final_path).exists()
|
||||
and not self.base_downloader.overwrite
|
||||
):
|
||||
raise MediaFileExists(download_item.final_path)
|
||||
playlist_file_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
playlist_file_path_parent_parts_len = len(playlist_file_path_obj.parent.parts)
|
||||
output_path_parts_len = len(output_dir_obj.parts)
|
||||
|
||||
if (
|
||||
self.base_downloader.download_mode == DownloadMode.NM3U8DLRE
|
||||
and not self.base_downloader.full_nm3u8dlre_path
|
||||
):
|
||||
raise ExecutableNotFound("N_m3u8DL-RE")
|
||||
|
||||
if download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
if (
|
||||
self.music_video_downloader.remux_mode == RemuxMode.FFMPEG
|
||||
and not self.base_downloader.full_ffmpeg_path
|
||||
):
|
||||
raise ExecutableNotFound("ffmpeg")
|
||||
|
||||
if (
|
||||
self.music_video_downloader.remux_mode == RemuxMode.MP4BOX
|
||||
and not self.base_downloader.full_mp4box_path
|
||||
):
|
||||
raise ExecutableNotFound("MP4Box")
|
||||
|
||||
if (
|
||||
download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
|
||||
or self.music_video_downloader.remux_mode == RemuxMode.MP4BOX
|
||||
) and not self.base_downloader.full_mp4decrypt_path:
|
||||
raise ExecutableNotFound("mp4decrypt")
|
||||
|
||||
if (
|
||||
not download_item.stream_info
|
||||
or not download_item.stream_info.audio_track
|
||||
or not download_item.stream_info.audio_track.stream_url
|
||||
or (
|
||||
(
|
||||
not download_item.decryption_key
|
||||
or not download_item.decryption_key.audio_track
|
||||
or not download_item.decryption_key.audio_track.key
|
||||
)
|
||||
and not self.base_downloader.use_wrapper
|
||||
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))
|
||||
)
|
||||
):
|
||||
raise FormatNotAvailable(download_item.media_metadata["id"])
|
||||
|
||||
if download_item.media_metadata["type"] in SONG_MEDIA_TYPE:
|
||||
await self.song_downloader.download(download_item)
|
||||
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)
|
||||
|
||||
if download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
await self.music_video_downloader.download(download_item)
|
||||
log.debug("success")
|
||||
|
||||
if download_item.media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
|
||||
await self.uploaded_video_downloader.download(download_item)
|
||||
def _write_cover(self, cover_path: str, cover_bytes: bytes) -> None:
|
||||
log = logger.bind(action="write_cover_file", cover_path=cover_path)
|
||||
|
||||
async def _initial_processing(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(cover_path, "wb") as f:
|
||||
f.write(cover_bytes)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
def _write_synced_lyrics(self, synced_lyrics_path: str, lyrics: str) -> None:
|
||||
log = logger.bind(
|
||||
action="write_synced_lyrics",
|
||||
synced_lyrics_path=synced_lyrics_path,
|
||||
)
|
||||
|
||||
Path(synced_lyrics_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(synced_lyrics_path, "w", encoding="utf-8") as f:
|
||||
f.write(lyrics)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
async def _initial_processing(self, item: DownloadItem) -> None:
|
||||
if self.skip_processing:
|
||||
return
|
||||
|
||||
if download_item.cover_path and self.base_downloader.save_cover:
|
||||
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
|
||||
if cover_bytes and (
|
||||
self.base_downloader.overwrite
|
||||
or not Path(download_item.cover_path).exists()
|
||||
):
|
||||
self.base_downloader.write_cover_image(
|
||||
if item.playlist_file_path and item.final_path and self.save_playlist:
|
||||
self._update_playlist_file(
|
||||
item.playlist_file_path,
|
||||
item.final_path,
|
||||
item.media.playlist_tags.track,
|
||||
)
|
||||
|
||||
if item.cover_path and self.save_cover and item.media.cover.url:
|
||||
cover_bytes = await self.base.interface.base.get_cover_bytes(
|
||||
item.media.cover.url,
|
||||
)
|
||||
if cover_bytes and (self.overwrite or not Path(item.cover_path).exists()):
|
||||
self._write_cover(
|
||||
item.cover_path,
|
||||
cover_bytes,
|
||||
download_item.cover_path,
|
||||
)
|
||||
|
||||
if (
|
||||
download_item.lyrics
|
||||
and download_item.lyrics.synced
|
||||
and not self.song_downloader.no_synced_lyrics
|
||||
and (
|
||||
self.base_downloader.overwrite
|
||||
or not Path(download_item.synced_lyrics_path).exists()
|
||||
)
|
||||
item.synced_lyrics_path
|
||||
and not self.no_synced_lyrics
|
||||
and item.media.lyrics
|
||||
and item.media.lyrics.synced
|
||||
and (self.overwrite or not Path(item.synced_lyrics_path).exists())
|
||||
):
|
||||
self.song_downloader.write_synced_lyrics(
|
||||
download_item.lyrics.synced,
|
||||
download_item.synced_lyrics_path,
|
||||
self._write_synced_lyrics(
|
||||
item.synced_lyrics_path,
|
||||
item.media.lyrics.synced,
|
||||
)
|
||||
|
||||
if download_item.playlist_tags and self.base_downloader.save_playlist:
|
||||
self.base_downloader.update_playlist_file(
|
||||
download_item.playlist_file_path,
|
||||
download_item.final_path,
|
||||
download_item.playlist_tags.playlist_track,
|
||||
)
|
||||
async def _download(self, item: DownloadItem) -> None:
|
||||
if item.media.error:
|
||||
raise item.media.error
|
||||
|
||||
if self.synced_lyrics_only:
|
||||
raise GamdlDownloaderSyncedLyricsOnlyError()
|
||||
|
||||
if Path(item.final_path).exists() and not self.overwrite:
|
||||
raise GamdlDownloaderMediaFileExistsError(item.final_path)
|
||||
|
||||
if item.media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
"songs",
|
||||
"library-songs",
|
||||
}:
|
||||
if (
|
||||
self.base.download_mode == DownloadMode.NM3U8DLRE
|
||||
and not self.base.full_nm3u8dlre_path
|
||||
):
|
||||
raise GamdlDownloaderDependencyNotFoundError("N_m3u8DL-RE")
|
||||
|
||||
if item.media.media_metadata["type"] in {"songs", "library-songs"}:
|
||||
await self.song.download(item)
|
||||
|
||||
elif item.media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
}:
|
||||
await self.music_video.download(item)
|
||||
|
||||
elif item.media.media_metadata["type"] in {"uploaded-videos"}:
|
||||
await self.uploaded_video.download(item)
|
||||
|
||||
def _move_to_final_path(self, staged_path: str, final_path: str) -> None:
|
||||
log = logger.bind(
|
||||
action="move_to_final_path",
|
||||
staged_path=staged_path,
|
||||
final_path=final_path,
|
||||
)
|
||||
|
||||
Path(final_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(staged_path, final_path)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
async def _final_processing(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
item: DownloadItem,
|
||||
) -> None:
|
||||
if self.skip_processing:
|
||||
return
|
||||
|
||||
if download_item.staged_path and Path(download_item.staged_path).exists():
|
||||
self.base_downloader.move_to_final_path(
|
||||
download_item.staged_path,
|
||||
download_item.final_path,
|
||||
if Path(item.staged_path).exists():
|
||||
self._move_to_final_path(
|
||||
item.staged_path,
|
||||
item.final_path,
|
||||
)
|
||||
|
||||
def _cleanup_temp(self, folder_tag: str) -> None:
|
||||
log = logger.bind(action="cleanup_temp", folder_tag=folder_tag)
|
||||
|
||||
temp_path = Path(self.base.temp_path) / TEMP_PATH_TEMPLATE.format(folder_tag)
|
||||
if temp_path.exists() and temp_path.is_dir():
|
||||
shutil.rmtree(temp_path, ignore_errors=True)
|
||||
log.debug("success")
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import CoverFormat, MusicVideoCodec, MusicVideoResolution
|
||||
from ..interface.interface_music_video import AppleMusicMusicVideoInterface
|
||||
from ..interface.types import DecryptionKeyAv
|
||||
from ..utils import async_subprocess
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .enums import RemuxFormatMusicVideo, RemuxMode
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicMusicVideoDownloader(AppleMusicBaseDownloader):
|
||||
def __init__(
|
||||
self,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
interface: AppleMusicMusicVideoInterface,
|
||||
codec_priority: list[MusicVideoCodec] = [
|
||||
MusicVideoCodec.H264,
|
||||
MusicVideoCodec.H265,
|
||||
],
|
||||
remux_mode: RemuxMode = RemuxMode.FFMPEG,
|
||||
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
|
||||
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
|
||||
):
|
||||
self.__dict__.update(base_downloader.__dict__)
|
||||
self.interface = interface
|
||||
self.codec_priority = codec_priority
|
||||
self.remux_mode = remux_mode
|
||||
self.remux_format = remux_format
|
||||
self.resolution = resolution
|
||||
|
||||
async def remux_mp4box(
|
||||
self,
|
||||
input_path_video: str,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.full_mp4box_path,
|
||||
"-quiet",
|
||||
"-add",
|
||||
input_path_audio,
|
||||
"-add",
|
||||
input_path_video,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
output_path,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def remux_ffmpeg(
|
||||
self,
|
||||
input_path_video: str,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
decryption_key: str = None,
|
||||
):
|
||||
if decryption_key:
|
||||
key = [
|
||||
"-decryption_key",
|
||||
decryption_key,
|
||||
]
|
||||
else:
|
||||
key = []
|
||||
|
||||
await async_subprocess(
|
||||
self.full_ffmpeg_path,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
*key,
|
||||
"-i",
|
||||
input_path_video,
|
||||
"-i",
|
||||
input_path_audio,
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
"mov_text",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
output_path,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def decrypt_mp4decrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.full_mp4decrypt_path,
|
||||
"--key",
|
||||
f"1:{decryption_key}",
|
||||
input_path,
|
||||
output_path,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path_video: str,
|
||||
encrypted_path_audio: str,
|
||||
decrypted_path_video: str,
|
||||
decrypted_path_audio: str,
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
):
|
||||
await self.decrypt_mp4decrypt(
|
||||
encrypted_path_video,
|
||||
decrypted_path_video,
|
||||
decryption_key.video_track.key,
|
||||
)
|
||||
await self.decrypt_mp4decrypt(
|
||||
encrypted_path_audio,
|
||||
decrypted_path_audio,
|
||||
decryption_key.audio_track.key,
|
||||
)
|
||||
|
||||
if self.remux_mode == RemuxMode.MP4BOX:
|
||||
await self.remux_mp4box(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
staged_path,
|
||||
)
|
||||
else:
|
||||
await self.remux_ffmpeg(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
staged_path,
|
||||
)
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(Path(final_path).with_suffix(file_extension))
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = music_video_metadata
|
||||
download_item.playlist_metadata = playlist_metadata
|
||||
|
||||
music_video_id = self.interface.get_media_id_of_library_media(
|
||||
music_video_metadata,
|
||||
)
|
||||
|
||||
itunes_page_metadata = await self.interface.get_itunes_page_metadata(
|
||||
music_video_metadata,
|
||||
)
|
||||
download_item.media_tags = await self.interface.get_tags(
|
||||
music_video_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
if playlist_metadata:
|
||||
download_item.playlist_tags = self.get_playlist_tags(
|
||||
playlist_metadata,
|
||||
music_video_metadata,
|
||||
)
|
||||
download_item.playlist_file_path = self.get_playlist_file_path(
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.stream_info = await self.interface.get_stream_info(
|
||||
music_video_metadata,
|
||||
itunes_page_metadata,
|
||||
self.codec_priority,
|
||||
self.resolution,
|
||||
)
|
||||
|
||||
download_item.decryption_key = await self.interface.get_decryption_key(
|
||||
download_item.stream_info,
|
||||
self.cdm,
|
||||
)
|
||||
|
||||
download_item.random_uuid = self.get_random_uuid()
|
||||
download_item.staged_path = self.get_temp_path(
|
||||
music_video_id,
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
(
|
||||
"."
|
||||
+ (
|
||||
"mp4"
|
||||
if self.remux_format == RemuxFormatMusicVideo.MP4
|
||||
else download_item.stream_info.file_format.value
|
||||
)
|
||||
),
|
||||
)
|
||||
download_item.final_path = self.get_final_path(
|
||||
download_item.media_tags,
|
||||
Path(download_item.staged_path).suffix,
|
||||
playlist_metadata,
|
||||
)
|
||||
|
||||
download_item.cover_url_template = self.interface.get_cover_url_template(
|
||||
music_video_metadata,
|
||||
self.cover_format,
|
||||
)
|
||||
download_item.cover_url = self.interface.get_cover_url(
|
||||
download_item.cover_url_template,
|
||||
self.cover_size,
|
||||
self.cover_format,
|
||||
)
|
||||
|
||||
cover_file_extension = await self.interface.get_cover_file_extension(
|
||||
download_item.cover_url,
|
||||
self.cover_format,
|
||||
)
|
||||
if cover_file_extension:
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
cover_file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
encrypted_path_video = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
encrypted_path_audio = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.download_stream(
|
||||
download_item.stream_info.video_track.stream_url,
|
||||
encrypted_path_video,
|
||||
)
|
||||
await self.download_stream(
|
||||
download_item.stream_info.audio_track.stream_url,
|
||||
encrypted_path_audio,
|
||||
)
|
||||
|
||||
decrypted_path_video = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"decrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
decrypted_path_audio = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"decrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path_video,
|
||||
encrypted_path_audio,
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
download_item.staged_path,
|
||||
download_item.decryption_key,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.interface.get_cover_bytes(download_item.cover_url)
|
||||
if self.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -1,248 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import CoverFormat, SongCodec, SyncedLyricsFormat
|
||||
from ..interface.interface_song import AppleMusicSongInterface
|
||||
from ..interface.types import DecryptionKeyAv
|
||||
from .amdecrypt import decrypt_file, decrypt_file_hex
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicSongDownloader(AppleMusicBaseDownloader):
|
||||
def __init__(
|
||||
self,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
interface: AppleMusicSongInterface,
|
||||
codec_priority: SongCodec = [SongCodec.AAC_LEGACY],
|
||||
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
|
||||
no_synced_lyrics: bool = False,
|
||||
synced_lyrics_only: bool = False,
|
||||
use_album_date: bool = False,
|
||||
fetch_extra_tags: bool = False,
|
||||
):
|
||||
self.__dict__.update(base_downloader.__dict__)
|
||||
self.interface = interface
|
||||
self.codec_priority = codec_priority
|
||||
self.synced_lyrics_format = synced_lyrics_format
|
||||
self.no_synced_lyrics = no_synced_lyrics
|
||||
self.synced_lyrics_only = synced_lyrics_only
|
||||
self.use_album_date = use_album_date
|
||||
self.fetch_extra_tags = fetch_extra_tags
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = song_metadata
|
||||
download_item.playlist_metadata = playlist_metadata
|
||||
|
||||
song_id = self.interface.get_media_id_of_library_media(song_metadata)
|
||||
|
||||
download_item.lyrics = await self.interface.get_lyrics(
|
||||
song_metadata,
|
||||
self.synced_lyrics_format,
|
||||
)
|
||||
|
||||
webplayback = await self.interface.apple_music_api.get_webplayback(song_id)
|
||||
download_item.media_tags = await self.interface.get_tags(
|
||||
webplayback,
|
||||
download_item.lyrics.unsynced if download_item.lyrics else None,
|
||||
self.use_album_date,
|
||||
)
|
||||
if self.fetch_extra_tags:
|
||||
download_item.extra_tags = await self.interface.get_extra_tags(
|
||||
song_metadata,
|
||||
)
|
||||
|
||||
if playlist_metadata:
|
||||
download_item.playlist_tags = self.get_playlist_tags(
|
||||
playlist_metadata,
|
||||
song_metadata,
|
||||
)
|
||||
download_item.playlist_file_path = self.get_playlist_file_path(
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.final_path = self.get_final_path(
|
||||
download_item.media_tags,
|
||||
".m4a",
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
download_item.synced_lyrics_path = self.get_lyrics_synced_path(
|
||||
download_item.final_path,
|
||||
)
|
||||
|
||||
if self.synced_lyrics_only:
|
||||
return download_item
|
||||
|
||||
for codec in self.codec_priority:
|
||||
download_item.stream_info = await self.interface.get_stream_info(
|
||||
codec,
|
||||
song_metadata,
|
||||
webplayback,
|
||||
)
|
||||
if download_item.stream_info:
|
||||
break
|
||||
|
||||
if download_item.stream_info.audio_track.legacy:
|
||||
download_item.decryption_key = (
|
||||
await self.interface.get_decryption_key_legacy(
|
||||
download_item.stream_info,
|
||||
self.cdm,
|
||||
)
|
||||
)
|
||||
elif (
|
||||
not self.use_wrapper
|
||||
and download_item.stream_info
|
||||
and download_item.stream_info.audio_track.widevine_pssh
|
||||
):
|
||||
download_item.decryption_key = await self.interface.get_decryption_key(
|
||||
download_item.stream_info,
|
||||
self.cdm,
|
||||
)
|
||||
|
||||
download_item.cover_url_template = self.interface.get_cover_url_template(
|
||||
song_metadata,
|
||||
self.cover_format,
|
||||
)
|
||||
download_item.cover_url = self.interface.get_cover_url(
|
||||
download_item.cover_url_template,
|
||||
self.cover_size,
|
||||
self.cover_format,
|
||||
)
|
||||
|
||||
download_item.random_uuid = self.get_random_uuid()
|
||||
if download_item.stream_info and download_item.stream_info.file_format:
|
||||
download_item.staged_path = self.get_temp_path(
|
||||
song_id,
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
"." + download_item.stream_info.file_format.value,
|
||||
)
|
||||
else:
|
||||
download_item.staged_path = None
|
||||
|
||||
cover_file_extension = await self.interface.get_cover_file_extension(
|
||||
download_item.cover_url,
|
||||
self.cover_format,
|
||||
)
|
||||
if cover_file_extension:
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
cover_file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def decrypt_amdecrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
media_id: str,
|
||||
fairplay_key: str,
|
||||
) -> None:
|
||||
await decrypt_file(
|
||||
self.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,
|
||||
):
|
||||
if self.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,
|
||||
)
|
||||
|
||||
def get_lyrics_synced_path(self, final_path: str) -> str:
|
||||
return str(Path(final_path).with_suffix("." + self.synced_lyrics_format.value))
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(Path(final_path).parent / ("Cover" + file_extension))
|
||||
|
||||
def write_synced_lyrics(
|
||||
self,
|
||||
synced_lyrics: str,
|
||||
lyrics_synced_path: str,
|
||||
):
|
||||
Path(lyrics_synced_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(lyrics_synced_path).write_text(synced_lyrics, encoding="utf8")
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
if self.synced_lyrics_only:
|
||||
return
|
||||
|
||||
encrypted_path = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted",
|
||||
".m4a",
|
||||
)
|
||||
await self.download_stream(
|
||||
download_item.stream_info.audio_track.stream_url,
|
||||
encrypted_path,
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path,
|
||||
download_item.staged_path,
|
||||
download_item.decryption_key,
|
||||
download_item.stream_info.audio_track.legacy,
|
||||
download_item.media_metadata["id"],
|
||||
download_item.stream_info.audio_track.fairplay_key,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.interface.get_cover_bytes(download_item.cover_url)
|
||||
if self.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
cover_bytes,
|
||||
download_item.extra_tags,
|
||||
)
|
||||
@@ -1,107 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import CoverFormat, UploadedVideoQuality
|
||||
from ..interface.interface_uploaded_video import AppleMusicUploadedVideoInterface
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoDownloader(AppleMusicBaseDownloader):
|
||||
def __init__(
|
||||
self,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
interface: AppleMusicUploadedVideoInterface,
|
||||
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
|
||||
):
|
||||
self.__dict__.update(base_downloader.__dict__)
|
||||
self.interface = interface
|
||||
self.quality = quality
|
||||
|
||||
def get_cover_path(self, final_path: str, file_extension: str) -> str:
|
||||
return str(Path(final_path).with_suffix(file_extension))
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
uploaded_video_metadata: dict,
|
||||
) -> DownloadItem:
|
||||
try:
|
||||
return await self._get_download_item(
|
||||
uploaded_video_metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
return DownloadItem(
|
||||
media_metadata=uploaded_video_metadata,
|
||||
error=e,
|
||||
)
|
||||
|
||||
async def _get_download_item(
|
||||
self,
|
||||
uploaded_video_metadata: dict,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = uploaded_video_metadata
|
||||
|
||||
download_item.media_tags = self.interface.get_tags(
|
||||
uploaded_video_metadata,
|
||||
)
|
||||
|
||||
download_item.stream_info = await self.interface.get_stream_info(
|
||||
uploaded_video_metadata,
|
||||
self.quality,
|
||||
)
|
||||
|
||||
download_item.random_uuid = self.get_random_uuid()
|
||||
download_item.staged_path = self.get_temp_path(
|
||||
uploaded_video_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
"." + download_item.stream_info.file_format.value,
|
||||
)
|
||||
download_item.final_path = self.get_final_path(
|
||||
download_item.media_tags,
|
||||
Path(download_item.staged_path).suffix,
|
||||
None,
|
||||
)
|
||||
|
||||
download_item.cover_url_template = self.interface.get_cover_url_template(
|
||||
uploaded_video_metadata,
|
||||
self.cover_format,
|
||||
)
|
||||
download_item.cover_url = self.interface.get_cover_url(
|
||||
download_item.cover_url_template,
|
||||
self.cover_size,
|
||||
self.cover_format,
|
||||
)
|
||||
|
||||
cover_file_extension = await self.interface.get_cover_file_extension(
|
||||
download_item.cover_url,
|
||||
self.cover_format,
|
||||
)
|
||||
if cover_file_extension:
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
cover_file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
await self.download_ytdlp(
|
||||
download_item.stream_info.video_track.stream_url,
|
||||
download_item.staged_path,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.interface.get_cover_bytes(download_item.cover_url)
|
||||
if self.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -1,10 +1,5 @@
|
||||
from enum import Enum
|
||||
|
||||
from .constants import (
|
||||
ARTIST_AUTO_SELECT_KEY_MAP,
|
||||
ARTIST_AUTO_SELECT_STR_MAP,
|
||||
)
|
||||
|
||||
|
||||
class DownloadMode(Enum):
|
||||
YTDLP = "ytdlp"
|
||||
@@ -19,20 +14,3 @@ class RemuxMode(Enum):
|
||||
class RemuxFormatMusicVideo(Enum):
|
||||
M4V = "m4v"
|
||||
MP4 = "mp4"
|
||||
|
||||
|
||||
class ArtistAutoSelect(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]
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
from ..utils import GamdlError
|
||||
|
||||
|
||||
class MediaFileExists(GamdlError):
|
||||
def __init__(self, media_path: str):
|
||||
super().__init__(f"Media file already exists at path: {media_path}")
|
||||
class GamdlDownloaderError(GamdlError):
|
||||
pass
|
||||
|
||||
|
||||
class NotStreamable(GamdlError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Media ID is not streamable: {media_id}")
|
||||
class GamdlDownloaderSyncedLyricsOnlyError(GamdlDownloaderError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Download mode is set to synced lyrics only")
|
||||
|
||||
|
||||
class FormatNotAvailable(GamdlError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Requested format is not available for media ID: {media_id}")
|
||||
class GamdlDownloaderMediaFileExistsError(GamdlDownloaderError):
|
||||
def __init__(self, file_path: str) -> None:
|
||||
super().__init__(f"Media file already exists: {file_path}")
|
||||
|
||||
|
||||
class ExecutableNotFound(GamdlError):
|
||||
def __init__(self, executable: str):
|
||||
super().__init__(f"Executable not found: {executable}")
|
||||
|
||||
|
||||
class SyncedLyricsOnly(GamdlError):
|
||||
def __init__(self):
|
||||
super().__init__("Only downloading synced lyrics is supported")
|
||||
|
||||
|
||||
class UnsupportedMediaType(GamdlError):
|
||||
def __init__(self, media_type: str):
|
||||
super().__init__(f"Unsupported media type: {media_type}")
|
||||
class GamdlDownloaderDependencyNotFoundError(GamdlDownloaderError):
|
||||
def __init__(self, dependency_name: str) -> None:
|
||||
super().__init__(f"Required dependency not found: {dependency_name}")
|
||||
|
||||
@@ -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,123 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
|
||||
from .amdecrypt import decrypt_file_hex, write_decrypted_media
|
||||
from .base import AppleMusicBaseDownloader
|
||||
from .enums import RemuxFormatMusicVideo, RemuxMode
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicMusicVideoDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseDownloader,
|
||||
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
|
||||
):
|
||||
self.base = base
|
||||
self.remux_format = remux_format
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path_video: str,
|
||||
encrypted_path_audio: str,
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
is_m4v: bool = False,
|
||||
):
|
||||
decrypted_media = await decrypt_file_hex(
|
||||
decryption_key.audio_track.key,
|
||||
encrypted_path_audio,
|
||||
decryption_key.video_track.key,
|
||||
encrypted_path_video,
|
||||
)
|
||||
await write_decrypted_media(
|
||||
decrypted_media,
|
||||
staged_path,
|
||||
m4v_brand=is_m4v,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path_video,
|
||||
encrypted_path_audio,
|
||||
download_item.staged_path,
|
||||
download_item.media.decryption_key,
|
||||
download_item.staged_path.endswith(".m4v"),
|
||||
)
|
||||
|
||||
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,200 @@
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
|
||||
from .amdecrypt import decrypt_file_hex, decrypt_wrapper, write_decrypted_media
|
||||
from .base import AppleMusicBaseDownloader
|
||||
from .types import DownloadItem
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicSongDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseDownloader,
|
||||
):
|
||||
self.base = base
|
||||
|
||||
async def get_download_item(self, media: AppleMusicMedia) -> DownloadItem:
|
||||
download_item = DownloadItem(media)
|
||||
|
||||
if media.stream_info:
|
||||
download_item.staged_path = self.base.get_temp_path(
|
||||
media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"staged",
|
||||
"." + media.stream_info.file_format.value,
|
||||
)
|
||||
|
||||
download_item.final_path = self.base.get_final_path(
|
||||
media.tags,
|
||||
".m4a",
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
if media.playlist_tags:
|
||||
download_item.playlist_file_path = self.base.get_playlist_file_path(
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.synced_lyrics_path = self.get_synced_lyrics_path(
|
||||
download_item.final_path
|
||||
)
|
||||
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
media.cover.file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def _decrypt_amdecrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
media_id: str,
|
||||
fairplay_key: str,
|
||||
use_single_content_key: bool = False,
|
||||
) -> None:
|
||||
wrapper_api = self.base.interface.base.wrapper_api
|
||||
if wrapper_api is None:
|
||||
raise ValueError("wrapper_api is required for FairPlay decrypt")
|
||||
|
||||
decrypted_media = await decrypt_wrapper(
|
||||
wrapper_api,
|
||||
media_id,
|
||||
input_path,
|
||||
fairplay_key_audio=fairplay_key,
|
||||
use_single_content_key=use_single_content_key,
|
||||
)
|
||||
await write_decrypted_media(decrypted_media, output_path)
|
||||
|
||||
async def _decrypt_amdecrypt_hex(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str,
|
||||
*,
|
||||
use_cenc: bool = False,
|
||||
use_single_content_key: bool = False,
|
||||
) -> None:
|
||||
decrypted_media = await decrypt_file_hex(
|
||||
decryption_key,
|
||||
input_path,
|
||||
use_cenc=use_cenc,
|
||||
use_single_content_key=use_single_content_key,
|
||||
)
|
||||
await write_decrypted_media(decrypted_media, output_path)
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path: str,
|
||||
staged_path: str,
|
||||
media_id: str,
|
||||
decryption_key: DecryptionKeyAv | None = None,
|
||||
fairplay_key: str = None,
|
||||
use_cenc: bool = False,
|
||||
use_single_content_key: bool = False,
|
||||
):
|
||||
log = logger.bind(
|
||||
action="stage_song",
|
||||
media_id=media_id,
|
||||
encrypted_path=encrypted_path,
|
||||
staged_path=staged_path,
|
||||
)
|
||||
|
||||
if decryption_key:
|
||||
await self._decrypt_amdecrypt_hex(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
decryption_key.audio_track.key,
|
||||
use_cenc=use_cenc,
|
||||
use_single_content_key=use_single_content_key,
|
||||
)
|
||||
else:
|
||||
await self._decrypt_amdecrypt(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
media_id,
|
||||
fairplay_key,
|
||||
use_single_content_key=use_single_content_key,
|
||||
)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
def get_synced_lyrics_path(self, final_path: str) -> str:
|
||||
log = logger.bind(action="get_synced_lyrics_path", final_path=final_path)
|
||||
|
||||
synced_lyrics_path = str(
|
||||
Path(final_path).with_suffix(
|
||||
"." + self.base.interface.song.synced_lyrics_format.value
|
||||
)
|
||||
)
|
||||
|
||||
log.debug("success", synced_lyrics_path=synced_lyrics_path)
|
||||
|
||||
return synced_lyrics_path
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
log = logger.bind(
|
||||
action="get_song_cover_path",
|
||||
final_path=final_path,
|
||||
file_extension=file_extension,
|
||||
)
|
||||
|
||||
cover_path = str(Path(final_path).parent / ("Cover" + file_extension))
|
||||
|
||||
log.debug("success", cover_path=cover_path)
|
||||
|
||||
return cover_path
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
if download_item.media.stream_info.audio_track.drm_free:
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.audio_track.stream_url,
|
||||
download_item.staged_path,
|
||||
)
|
||||
else:
|
||||
encrypted_path = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"encrypted",
|
||||
".m4a",
|
||||
)
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.audio_track.stream_url,
|
||||
encrypted_path,
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path,
|
||||
download_item.staged_path,
|
||||
download_item.media.media_id,
|
||||
download_item.media.decryption_key,
|
||||
download_item.media.stream_info.audio_track.fairplay_key,
|
||||
download_item.media.stream_info.audio_track.use_cenc,
|
||||
download_item.media.stream_info.audio_track.use_single_content_key,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.base.interface.base.get_cover_bytes(
|
||||
download_item.media.cover.url
|
||||
)
|
||||
if self.base.interface.base.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.base.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media.tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -1,44 +1,15 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ..interface.types import (
|
||||
DecryptionKeyAv,
|
||||
Lyrics,
|
||||
MediaTags,
|
||||
PlaylistTags,
|
||||
StreamInfoAv,
|
||||
)
|
||||
from ..interface.types import AppleMusicMedia
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadItem:
|
||||
media_metadata: dict = None
|
||||
playlist_metadata: dict = None
|
||||
random_uuid: str = None
|
||||
lyrics: Lyrics = None
|
||||
media_tags: MediaTags = None
|
||||
extra_tags: dict = None
|
||||
playlist_tags: PlaylistTags = None
|
||||
stream_info: StreamInfoAv = None
|
||||
decryption_key: DecryptionKeyAv = None
|
||||
cover_url_template: str = None
|
||||
cover_url: str = None
|
||||
media: AppleMusicMedia
|
||||
uuid_: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
|
||||
staged_path: str = None
|
||||
final_path: str = None
|
||||
playlist_file_path: str = None
|
||||
synced_lyrics_path: str = None
|
||||
cover_path: str = None
|
||||
flat_filter_result: Any = None
|
||||
error: Exception = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UrlInfo:
|
||||
storefront: str = None
|
||||
type: str = None
|
||||
slug: str = None
|
||||
id: str = None
|
||||
sub_id: str = None
|
||||
library_storefront: str = None
|
||||
library_type: str = None
|
||||
library_id: str = None
|
||||
|
||||
@@ -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,404 @@
|
||||
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 ..api.wrapper import WrapperApi
|
||||
from .constants import IMAGE_FILE_EXTENSION_MAP
|
||||
from .enums import CoverFormat
|
||||
from .types import Cover, DecryptionKey, MediaRating, MediaTags, MediaType, PlaylistTags
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicBaseInterface:
|
||||
def __init__(
|
||||
self,
|
||||
apple_music_api: AppleMusicApi,
|
||||
itunes_api: ItunesApi,
|
||||
wrapper_api: WrapperApi | None,
|
||||
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
|
||||
self.wrapper_api = wrapper_api
|
||||
|
||||
@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_media_id_from_url(media_metadata: dict) -> str | None:
|
||||
media_url = media_metadata["attributes"].get("url")
|
||||
if media_url is None:
|
||||
return None
|
||||
|
||||
url_media_id = media_url.split("/")[-1].split("?")[0]
|
||||
|
||||
return url_media_id
|
||||
|
||||
@staticmethod
|
||||
def parse_date(date: str) -> datetime.datetime:
|
||||
return datetime.datetime.fromisoformat(date.split("Z")[0])
|
||||
|
||||
@staticmethod
|
||||
def reconstruct_pssh(pssh: str) -> bytes:
|
||||
pssh = pssh.split(",")[-1]
|
||||
|
||||
decoded_pssh = base64.b64decode(pssh)
|
||||
if len(decoded_pssh) > 30:
|
||||
return pssh
|
||||
|
||||
widevine_pssh_data = WidevinePsshData(
|
||||
algorithm=1,
|
||||
key_ids=[decoded_pssh],
|
||||
)
|
||||
|
||||
return widevine_pssh_data.SerializeToString()
|
||||
|
||||
@staticmethod
|
||||
async def get_response(
|
||||
url: str,
|
||||
valid_responses: list[int] = [200],
|
||||
) -> httpx.Response:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
try:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code in valid_responses:
|
||||
return e.response
|
||||
raise e
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def format_cover(
|
||||
template_cover_url: str,
|
||||
cover_size: int,
|
||||
cover_format: CoverFormat,
|
||||
) -> str:
|
||||
return re.sub(
|
||||
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
|
||||
f"/{cover_size}x{cover_size}bb.{cover_format.value}",
|
||||
template_cover_url,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_catalog_metadata_from_library(library_metadata: dict) -> dict | None:
|
||||
data = library_metadata.get("relationships", {}).get("catalog", {}).get("data")
|
||||
if not data:
|
||||
return None
|
||||
|
||||
return data[0]
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
apple_music_api: AppleMusicApi,
|
||||
cover_format: CoverFormat = CoverFormat.JPG,
|
||||
cover_size: int = 1200,
|
||||
wvd_path: str | None = None,
|
||||
itunes_api: ItunesApi | None = None,
|
||||
wrapper_api: WrapperApi | None = None,
|
||||
):
|
||||
itunes_api = itunes_api or await ItunesApi.create(
|
||||
storefront=apple_music_api.storefront,
|
||||
language=apple_music_api.language,
|
||||
**(
|
||||
{"storefront_id": None}
|
||||
if apple_music_api.storefront.lower() != "us"
|
||||
else {}
|
||||
),
|
||||
)
|
||||
cdm = cls.create_cdm(wvd_path)
|
||||
|
||||
base = cls(
|
||||
apple_music_api=apple_music_api,
|
||||
itunes_api=itunes_api,
|
||||
cover_format=cover_format,
|
||||
cover_size=cover_size,
|
||||
cdm=cdm,
|
||||
wrapper_api=wrapper_api,
|
||||
)
|
||||
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"/\{w\}x\{h\}bb\.jpg",
|
||||
"",
|
||||
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=metadata["id"])
|
||||
|
||||
template_url = self._get_cover_template_url(metadata)
|
||||
|
||||
if self.cover_format == CoverFormat.RAW:
|
||||
cover_url = template_url
|
||||
else:
|
||||
cover_url = self.format_cover(
|
||||
template_url,
|
||||
self.cover_size,
|
||||
self.cover_format,
|
||||
)
|
||||
|
||||
cover_file_extension = await self._get_cover_file_extension(cover_url)
|
||||
|
||||
cover = Cover(
|
||||
template_url=template_url,
|
||||
url=cover_url,
|
||||
file_extension=cover_file_extension,
|
||||
)
|
||||
|
||||
log.debug("success", cover=cover)
|
||||
|
||||
return cover
|
||||
|
||||
@alru_cache()
|
||||
async def get_media_date(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> datetime.datetime | None:
|
||||
log = logger.bind(action="get_media_date", media_id=media_id)
|
||||
|
||||
lookup_result = await self.itunes_api.get_lookup_result(media_id)
|
||||
if not lookup_result["results"]:
|
||||
log.debug("no_media_id")
|
||||
return None
|
||||
|
||||
release_date = lookup_result["results"][0].get("releaseDate")
|
||||
if not release_date:
|
||||
log.debug("no_release_date")
|
||||
return None
|
||||
|
||||
parsed_date = self.parse_date(release_date)
|
||||
|
||||
log.debug("success", release_date=parsed_date)
|
||||
|
||||
return parsed_date
|
||||
|
||||
def get_playlist_tags(
|
||||
self,
|
||||
playlist_metadata: dict,
|
||||
playlist_track: int,
|
||||
) -> PlaylistTags:
|
||||
log = logger.bind(
|
||||
action="get_playlist_tags",
|
||||
playlist_id=playlist_metadata["id"],
|
||||
)
|
||||
|
||||
playlist_tags = PlaylistTags(
|
||||
artist=playlist_metadata["attributes"].get("curatorName", "Unknown"),
|
||||
playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
|
||||
title=playlist_metadata["attributes"]["name"],
|
||||
track=playlist_track,
|
||||
)
|
||||
|
||||
log.debug("success", playlist_tags=playlist_tags)
|
||||
|
||||
return playlist_tags
|
||||
|
||||
async def get_tags_from_asset_info(
|
||||
self,
|
||||
asset_data: dict,
|
||||
lyrics: str | None = None,
|
||||
use_album_date: bool = False,
|
||||
) -> MediaTags:
|
||||
log = logger.bind(
|
||||
action="get_tags_from_asset_info", asset_id=asset_data["itemId"]
|
||||
)
|
||||
|
||||
tags = MediaTags(
|
||||
album=asset_data.get("playlistName"),
|
||||
album_artist=asset_data.get("playlistArtistName"),
|
||||
album_id=(
|
||||
int(asset_data["playlistId"]) if asset_data.get("playlistId") else None
|
||||
),
|
||||
album_sort=asset_data.get("sort-album"),
|
||||
artist=asset_data["artistName"],
|
||||
artist_id=(
|
||||
int(asset_data["artistId"]) if asset_data.get("artistId") else None
|
||||
),
|
||||
artist_sort=asset_data["sort-artist"],
|
||||
comment=asset_data.get("comments"),
|
||||
compilation=asset_data.get("compilation"),
|
||||
composer=asset_data.get("composerName"),
|
||||
composer_id=(
|
||||
int(asset_data.get("composerId"))
|
||||
if asset_data.get("composerId")
|
||||
else None
|
||||
),
|
||||
composer_sort=asset_data.get("sort-composer"),
|
||||
copyright=asset_data.get("copyright"),
|
||||
date=(
|
||||
await self.get_media_date(asset_data["playlistId"])
|
||||
if use_album_date
|
||||
else (
|
||||
self.parse_date(asset_data["releaseDate"])
|
||||
if asset_data.get("releaseDate")
|
||||
else None
|
||||
)
|
||||
),
|
||||
disc=asset_data.get("discNumber"),
|
||||
disc_total=asset_data.get("discCount"),
|
||||
gapless=asset_data.get("gapless"),
|
||||
genre=asset_data.get("genre"),
|
||||
genre_id=(
|
||||
int(asset_data["genreId"]) if asset_data.get("genreId") else None
|
||||
),
|
||||
lyrics=lyrics if lyrics else None,
|
||||
media_type=(
|
||||
MediaType.SONG
|
||||
if asset_data["kind"] == "song"
|
||||
else MediaType.MUSIC_VIDEO
|
||||
),
|
||||
rating=MediaRating(asset_data["explicit"]),
|
||||
storefront=(int(asset_data["s"]) if asset_data.get("s") else None),
|
||||
title=asset_data["itemName"],
|
||||
title_id=int(asset_data["itemId"]),
|
||||
title_sort=asset_data["sort-name"],
|
||||
track=asset_data.get("trackNumber"),
|
||||
track_total=asset_data.get("trackCount"),
|
||||
xid=asset_data.get("xid"),
|
||||
)
|
||||
|
||||
log.debug("success", tags=tags)
|
||||
|
||||
return tags
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
MEDIA_TYPE_STR_MAP = {
|
||||
1: "Song",
|
||||
6: "Music Video",
|
||||
@@ -9,8 +11,6 @@ MEDIA_RATING_STR_MAP = {
|
||||
2: "Clean",
|
||||
}
|
||||
|
||||
LEGACY_SONG_CODECS = {"aac-legacy", "aac-he-legacy"}
|
||||
|
||||
DRM_DEFAULT_KEY_MAPPING = {
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed": (
|
||||
"data:text/plain;base64,AAAAOHBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAABgSEAAAAAA"
|
||||
@@ -60,3 +60,44 @@ 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|songs|music-videos)"
|
||||
r"/(?P<library_id>[pli]\.[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",
|
||||
}
|
||||
|
||||
SONG_CODEC_FLAVOR_MAP = {
|
||||
"aac-web": "28:ctrp256",
|
||||
"aac-he-web": "32:ctrp64",
|
||||
"aac-fps-web": "30:cbcp256",
|
||||
"aac-he-fps-web": "34:cbcp64",
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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,
|
||||
MEDIA_TYPE_STR_MAP,
|
||||
SONG_CODEC_FLAVOR_MAP,
|
||||
)
|
||||
|
||||
|
||||
@@ -44,8 +46,11 @@ class MediaFileFormat(Enum):
|
||||
|
||||
|
||||
class SongCodec(Enum):
|
||||
AAC_LEGACY = "aac-legacy"
|
||||
AAC_HE_LEGACY = "aac-he-legacy"
|
||||
AAC_WEB = "aac-web"
|
||||
AAC_HE_WEB = "aac-he-web"
|
||||
# doesnt work with wrapper, gives ckc error
|
||||
# AAC_FPS_WEB = "aac-fps-web"
|
||||
# AAC_HE_FPS_WEB = "aac-he-fps-web"
|
||||
AAC = "aac"
|
||||
AAC_HE = "aac-he"
|
||||
AAC_BINAURAL = "aac-binaural"
|
||||
@@ -57,8 +62,17 @@ class SongCodec(Enum):
|
||||
ALAC = "alac"
|
||||
ASK = "ask"
|
||||
|
||||
def is_legacy(self) -> bool:
|
||||
return self.value in LEGACY_SONG_CODECS
|
||||
@property
|
||||
def is_web(self) -> bool:
|
||||
return self.value.endswith("-web")
|
||||
|
||||
@property
|
||||
def flavor(self) -> str | None:
|
||||
return SONG_CODEC_FLAVOR_MAP.get(self.value)
|
||||
|
||||
@property
|
||||
def is_cenc(self) -> bool:
|
||||
return self.flavor is not None and "ctrp" in self.flavor
|
||||
|
||||
|
||||
class MusicVideoCodec(Enum):
|
||||
@@ -93,3 +107,20 @@ class CoverFormat(Enum):
|
||||
JPG = "jpg"
|
||||
PNG = "png"
|
||||
RAW = "raw"
|
||||
|
||||
|
||||
class ArtistMediaType(Enum):
|
||||
MAIN_ALBUMS = "main-albums"
|
||||
COMPILATION_ALBUMS = "compilation-albums"
|
||||
LIVE_ALBUMS = "live-albums"
|
||||
SINGLES_EPS = "singles-eps"
|
||||
ALL_ALBUMS = "all-albums"
|
||||
TOP_SONGS = "top-songs"
|
||||
MUSIC_VIDEOS = "music-videos"
|
||||
|
||||
@property
|
||||
def path_key(self) -> tuple[str, str]:
|
||||
return ARTIST_AUTO_SELECT_KEY_MAP[self.value]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return ARTIST_AUTO_SELECT_STR_MAP[self.value]
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from ..utils import GamdlError
|
||||
from typing import Any
|
||||
|
||||
|
||||
class GamdlInterfaceError(GamdlError):
|
||||
pass
|
||||
|
||||
|
||||
class GamdlInterfaceMediaNotStreamableError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Media is not streamable: {media_id}")
|
||||
|
||||
|
||||
class GamdlInterfaceFormatNotAvailableError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str, codec: Any | None = None):
|
||||
super().__init__(
|
||||
f"Requested format is not available (media ID: {media_id}): {codec}"
|
||||
)
|
||||
|
||||
|
||||
class GamdlInterfaceDecryptionNotAvailableError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Decryption is not available for media ID: {media_id}")
|
||||
|
||||
|
||||
class GamdlInterfaceMediaNotAllowedError(GamdlInterfaceError):
|
||||
def __init__(self, media_type: str, media_id: str | None = None):
|
||||
message = "Media type is disallowed"
|
||||
if media_id:
|
||||
message += f" (media ID: {media_id})"
|
||||
|
||||
super().__init__(f"{message}: {media_type}")
|
||||
|
||||
|
||||
class GamdlInterfaceUrlParseError(GamdlInterfaceError):
|
||||
def __init__(self, url: str):
|
||||
super().__init__(f"URL is not valid or supported: {url}")
|
||||
|
||||
|
||||
class GamdlInterfaceArtistMediaTypeError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str, media_type: str):
|
||||
super().__init__(
|
||||
f"Artist has no media of type (media ID: {media_id}): {media_type}"
|
||||
)
|
||||
|
||||
|
||||
class GamdlInterfaceFlatFilterExcludedError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str, result: Any):
|
||||
super().__init__(
|
||||
f"Media excluded by flat filter (media ID: {media_id}): {result}"
|
||||
)
|
||||
|
||||
self.result = result
|
||||
+456
-137
@@ -1,161 +1,480 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from io import BytesIO
|
||||
from typing import Any, AsyncGenerator, Callable
|
||||
|
||||
from async_lru import alru_cache
|
||||
from PIL import Image
|
||||
from pywidevine import PSSH, Cdm
|
||||
import structlog
|
||||
|
||||
from ..api.apple_music_api import AppleMusicApi
|
||||
from ..api.itunes_api import ItunesApi
|
||||
from ..utils import get_response
|
||||
from .constants import IMAGE_FILE_EXTENSION_MAP
|
||||
from .enums import CoverFormat
|
||||
from .types import DecryptionKey
|
||||
from ..utils import safe_gather
|
||||
from .constants import VALID_URL_PATTERN
|
||||
from .enums import ArtistMediaType
|
||||
from .exceptions import (
|
||||
GamdlInterfaceMediaNotAllowedError,
|
||||
GamdlInterfaceUrlParseError,
|
||||
GamdlInterfaceArtistMediaTypeError,
|
||||
GamdlInterfaceFlatFilterExcludedError,
|
||||
)
|
||||
from .music_video import AppleMusicMusicVideoInterface
|
||||
from .song import AppleMusicSongInterface
|
||||
from .types import AppleMusicMedia, AppleMusicUrlInfo
|
||||
from .uploaded_video import AppleMusicUploadedVideoInterface
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicInterface:
|
||||
def __init__(
|
||||
self,
|
||||
apple_music_api: AppleMusicApi,
|
||||
itunes_api: ItunesApi,
|
||||
song: AppleMusicSongInterface,
|
||||
music_video: AppleMusicMusicVideoInterface,
|
||||
uploaded_video: AppleMusicUploadedVideoInterface,
|
||||
artist_select_media_type_function: (
|
||||
Callable[[list[ArtistMediaType], dict], ArtistMediaType | None] | None
|
||||
) = None,
|
||||
artist_select_items_function: (
|
||||
Callable[[ArtistMediaType, list[dict]], list[dict] | None] | None
|
||||
) = None,
|
||||
flat_filter_function: Callable[[dict], Any] | None = None,
|
||||
concurrency: int = 1,
|
||||
disallowed_media_types: list[str] | None = None,
|
||||
) -> None:
|
||||
self.apple_music_api = apple_music_api
|
||||
self.itunes_api = itunes_api
|
||||
self.song = song
|
||||
self.music_video = music_video
|
||||
self.uploaded_video = uploaded_video
|
||||
self.artist_select_media_type_function = artist_select_media_type_function
|
||||
self.artist_select_items_function = artist_select_items_function
|
||||
self.flat_filter_function = flat_filter_function
|
||||
self.concurrency = concurrency
|
||||
self.disallowed_media_types = disallowed_media_types
|
||||
|
||||
self.base = song.base
|
||||
|
||||
@staticmethod
|
||||
def get_media_id_of_library_media(library_media_metadata: dict) -> str:
|
||||
play_params = library_media_metadata["attributes"].get("playParams", {})
|
||||
return play_params.get("catalogId", library_media_metadata["id"])
|
||||
def get_url_info(url: str) -> AppleMusicUrlInfo | None:
|
||||
log = logger.bind(action="get_url_info", url=url)
|
||||
|
||||
@staticmethod
|
||||
def parse_date(date: str) -> datetime.datetime:
|
||||
return datetime.datetime.fromisoformat(date.split("Z")[0])
|
||||
match = VALID_URL_PATTERN.match(url)
|
||||
if not match:
|
||||
log.debug("invalid_url_pattern")
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
track_uri: str,
|
||||
track_id: str,
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKey:
|
||||
try:
|
||||
cdm_session = cdm.open()
|
||||
|
||||
pssh_obj = PSSH(track_uri.split(",")[-1])
|
||||
|
||||
challenge = base64.b64encode(
|
||||
await asyncio.to_thread(
|
||||
cdm.get_license_challenge, cdm_session, pssh_obj
|
||||
)
|
||||
).decode()
|
||||
license = await self.apple_music_api.get_license_exchange(
|
||||
track_id,
|
||||
track_uri,
|
||||
challenge,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(cdm.parse_license, cdm_session, license["license"])
|
||||
decryption_key_info = next(
|
||||
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
)
|
||||
finally:
|
||||
cdm.close(cdm_session)
|
||||
|
||||
decryption_key = DecryptionKey(
|
||||
key=decryption_key_info.key.hex(),
|
||||
kid=decryption_key_info.kid.hex,
|
||||
)
|
||||
logger.debug(f"Decryption key: {decryption_key}")
|
||||
|
||||
return decryption_key
|
||||
|
||||
def get_cover_url_template(self, metadata: dict, cover_format: CoverFormat) -> str:
|
||||
if cover_format == CoverFormat.RAW:
|
||||
cover_url_template = self._get_raw_cover_url(
|
||||
metadata["attributes"]["artwork"]["url"]
|
||||
)
|
||||
else:
|
||||
cover_url_template = metadata["attributes"]["artwork"]["url"]
|
||||
|
||||
logger.debug(f"Cover URL template: {cover_url_template}")
|
||||
return cover_url_template
|
||||
|
||||
def _get_raw_cover_url(self, cover_url_template: str) -> str:
|
||||
return re.sub(
|
||||
r"image/thumb/",
|
||||
"",
|
||||
re.sub(
|
||||
r"is1-ssl",
|
||||
"a1",
|
||||
cover_url_template,
|
||||
),
|
||||
)
|
||||
|
||||
def get_cover_url(
|
||||
self,
|
||||
cover_url_template: str,
|
||||
cover_size: int,
|
||||
cover_format: CoverFormat,
|
||||
) -> str:
|
||||
cover_url = re.sub(
|
||||
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
|
||||
(
|
||||
f"/{cover_size}x{cover_size}bb.{cover_format.value}"
|
||||
if cover_format != CoverFormat.RAW
|
||||
else ""
|
||||
),
|
||||
cover_url_template,
|
||||
)
|
||||
|
||||
logger.debug(f"Cover URL: {cover_url}")
|
||||
return cover_url
|
||||
|
||||
@alru_cache()
|
||||
async def get_cover_file_extension(
|
||||
self,
|
||||
cover_url: str,
|
||||
cover_format: CoverFormat,
|
||||
) -> str | None:
|
||||
if cover_format != CoverFormat.RAW:
|
||||
return f".{cover_format.value}"
|
||||
|
||||
cover_bytes = await self.get_cover_bytes(cover_url)
|
||||
if cover_bytes is None:
|
||||
return None
|
||||
|
||||
image_obj = Image.open(BytesIO(await self.get_cover_bytes(cover_url)))
|
||||
image_format = image_obj.format.lower()
|
||||
return IMAGE_FILE_EXTENSION_MAP.get(
|
||||
image_format,
|
||||
f".{image_format.lower()}",
|
||||
url_match = AppleMusicUrlInfo(
|
||||
**match.groupdict(),
|
||||
)
|
||||
|
||||
@alru_cache()
|
||||
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
|
||||
response = await get_response(cover_url, {200, 404})
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
return None
|
||||
log.debug("success", url_info=url_match)
|
||||
|
||||
@alru_cache()
|
||||
async def get_media_date(
|
||||
return url_match
|
||||
|
||||
async def _run_flat_filter(self, media: AppleMusicMedia) -> None:
|
||||
if not self.flat_filter_function or not media.partial:
|
||||
return
|
||||
|
||||
result = self.flat_filter_function(media.media_metadata)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
if result:
|
||||
raise GamdlInterfaceFlatFilterExcludedError(media.media_id, result)
|
||||
|
||||
def _run_media_type_filter(self, media: AppleMusicMedia) -> None:
|
||||
if not self.disallowed_media_types or not media.partial:
|
||||
return
|
||||
|
||||
if media.media_metadata["type"] in self.disallowed_media_types:
|
||||
raise GamdlInterfaceMediaNotAllowedError(
|
||||
media.media_metadata["type"],
|
||||
media.media_id,
|
||||
)
|
||||
|
||||
async def _collect_generator(
|
||||
self, generator_or_coroutine: AsyncGenerator[AppleMusicMedia, None]
|
||||
) -> list[AppleMusicMedia]:
|
||||
results = []
|
||||
async for result in generator_or_coroutine:
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
async def _get_song_media(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> datetime.datetime | None:
|
||||
lookup_result = await self.itunes_api.get_lookup_result(media_id)
|
||||
if not lookup_result["results"]:
|
||||
return None
|
||||
index: int | None = None,
|
||||
total: int | None = None,
|
||||
media_metadata: dict | None = None,
|
||||
playlist_metadata: dict | None = None,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
media = AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
is_library=is_library,
|
||||
index=index,
|
||||
total=total,
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
)
|
||||
try:
|
||||
async for media in self.song.get_media(media):
|
||||
yield media
|
||||
|
||||
release_date = lookup_result["results"][0].get("releaseDate")
|
||||
if not release_date:
|
||||
return None
|
||||
self._run_media_type_filter(media)
|
||||
await self._run_flat_filter(media)
|
||||
except Exception as e:
|
||||
media.partial = False
|
||||
media.error = e
|
||||
yield media
|
||||
return
|
||||
|
||||
parsed_date = self.parse_date(release_date)
|
||||
logger.debug(f"Parsed media date: {parsed_date}")
|
||||
async def _get_music_video_media(
|
||||
self,
|
||||
media_id: str,
|
||||
index: int | None = None,
|
||||
total: int | None = None,
|
||||
media_metadata: dict | None = None,
|
||||
playlist_metadata: dict | None = None,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
media = AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
is_library=is_library,
|
||||
index=index,
|
||||
total=total,
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
)
|
||||
|
||||
return parsed_date
|
||||
media.media_metadata = media_metadata
|
||||
media.playlist_metadata = playlist_metadata
|
||||
|
||||
try:
|
||||
async for media in self.music_video.get_media(media):
|
||||
yield media
|
||||
|
||||
self._run_media_type_filter(media)
|
||||
await self._run_flat_filter(media)
|
||||
except Exception as e:
|
||||
media.partial = False
|
||||
media.error = e
|
||||
yield media
|
||||
return
|
||||
|
||||
async def _get_uploaded_video_media(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
media = AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
)
|
||||
|
||||
try:
|
||||
async for media in self.music_video.get_media(media):
|
||||
yield
|
||||
|
||||
self._run_media_type_filter(media)
|
||||
await self._run_flat_filter(media)
|
||||
except Exception as e:
|
||||
media.partial = False
|
||||
media.error = e
|
||||
yield media
|
||||
return
|
||||
|
||||
async def _get_album_media(
|
||||
self,
|
||||
media_id: str,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
base_media = AppleMusicMedia(media_id)
|
||||
|
||||
try:
|
||||
base_media.media_metadata = (
|
||||
await (
|
||||
self.base.apple_music_api.get_library_album(
|
||||
media_id,
|
||||
)
|
||||
if is_library
|
||||
else self.base.apple_music_api.get_album(
|
||||
media_id,
|
||||
)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
self._run_media_type_filter(base_media)
|
||||
await self._run_flat_filter(base_media)
|
||||
except Exception as e:
|
||||
base_media.partial = False
|
||||
base_media.error = e
|
||||
yield base_media
|
||||
return
|
||||
|
||||
yield base_media
|
||||
|
||||
tracks = base_media.media_metadata["relationships"]["tracks"]["data"]
|
||||
tasks = [
|
||||
(
|
||||
self._get_song_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
total=base_media.media_metadata["attributes"]["trackCount"],
|
||||
media_metadata=track,
|
||||
is_library=is_library,
|
||||
)
|
||||
if track["type"] in {"songs", "library-songs"}
|
||||
else self._get_music_video_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
total=base_media.media_metadata["attributes"]["trackCount"],
|
||||
media_metadata=track,
|
||||
is_library=is_library,
|
||||
)
|
||||
)
|
||||
for index, track in enumerate(tracks)
|
||||
]
|
||||
|
||||
if self.concurrency == 1:
|
||||
for task in tasks:
|
||||
async for media in task:
|
||||
yield media
|
||||
else:
|
||||
collected_tasks = [self._collect_generator(task) for task in tasks]
|
||||
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
|
||||
for batch in batches:
|
||||
for media in batch:
|
||||
yield media
|
||||
|
||||
async def _get_playlist_media(
|
||||
self,
|
||||
media_id: str,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
base_media = AppleMusicMedia(media_id)
|
||||
|
||||
try:
|
||||
base_media.media_metadata = (
|
||||
await (
|
||||
self.base.apple_music_api.get_library_playlist(
|
||||
media_id,
|
||||
)
|
||||
if is_library
|
||||
else self.base.apple_music_api.get_playlist(
|
||||
media_id,
|
||||
)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
self._run_media_type_filter(base_media)
|
||||
await self._run_flat_filter(base_media)
|
||||
|
||||
tracks = base_media.media_metadata["relationships"]["tracks"]["data"]
|
||||
next_uri = base_media.media_metadata["relationships"]["tracks"].get("next")
|
||||
href_uri = base_media.media_metadata["relationships"]["tracks"].get("href")
|
||||
while next_uri:
|
||||
extended_data = await self.base.apple_music_api.get_extended_api_data(
|
||||
next_uri,
|
||||
href_uri,
|
||||
)
|
||||
tracks.extend(extended_data["data"])
|
||||
next_uri = extended_data.get("next")
|
||||
except Exception as e:
|
||||
base_media.partial = False
|
||||
base_media.error = e
|
||||
yield base_media
|
||||
return
|
||||
|
||||
yield base_media
|
||||
|
||||
tasks = [
|
||||
(
|
||||
self._get_song_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
media_metadata=track,
|
||||
playlist_metadata=base_media.media_metadata,
|
||||
is_library=is_library,
|
||||
)
|
||||
if track["type"] in {"songs", "library-songs"}
|
||||
else self._get_music_video_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
media_metadata=track,
|
||||
playlist_metadata=base_media.media_metadata,
|
||||
is_library=is_library,
|
||||
)
|
||||
)
|
||||
for index, track in enumerate(tracks)
|
||||
]
|
||||
|
||||
if self.concurrency == 1:
|
||||
for task in tasks:
|
||||
async for media in task:
|
||||
yield media
|
||||
else:
|
||||
collected_tasks = [self._collect_generator(task) for task in tasks]
|
||||
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
|
||||
for batch in batches:
|
||||
for media in batch:
|
||||
yield media
|
||||
|
||||
async def _get_artist_media(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
base_media = AppleMusicMedia(media_id)
|
||||
|
||||
try:
|
||||
base_media.media_metadata = (
|
||||
await self.base.apple_music_api.get_artist(
|
||||
media_id,
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
self._run_media_type_filter(base_media)
|
||||
await self._run_flat_filter(base_media)
|
||||
|
||||
if self.artist_select_media_type_function:
|
||||
artist_media_type = self.artist_select_media_type_function(
|
||||
list(ArtistMediaType),
|
||||
base_media.media_metadata,
|
||||
)
|
||||
if asyncio.iscoroutine(artist_media_type):
|
||||
artist_media_type = await artist_media_type
|
||||
else:
|
||||
artist_media_type = list(ArtistMediaType)[0]
|
||||
|
||||
relation_key, type_key = artist_media_type.path_key
|
||||
|
||||
items_relation = base_media.media_metadata.get(relation_key, {}).get(
|
||||
type_key, {}
|
||||
)
|
||||
items = items_relation.get("data", [])
|
||||
if not items:
|
||||
raise GamdlInterfaceArtistMediaTypeError(
|
||||
base_media.media_id,
|
||||
str(artist_media_type),
|
||||
)
|
||||
|
||||
next_uri = items_relation.get("next")
|
||||
href_uri = items_relation.get("href")
|
||||
while next_uri:
|
||||
extended_data = await self.base.apple_music_api.get_extended_api_data(
|
||||
next_uri,
|
||||
href_uri,
|
||||
)
|
||||
items.extend(extended_data.get("data", []))
|
||||
next_uri = extended_data.get("next")
|
||||
except Exception as e:
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=None,
|
||||
error=e,
|
||||
)
|
||||
return
|
||||
|
||||
yield base_media
|
||||
|
||||
if self.artist_select_items_function:
|
||||
selected_items = self.artist_select_items_function(
|
||||
artist_media_type,
|
||||
items,
|
||||
)
|
||||
if asyncio.iscoroutine(selected_items):
|
||||
selected_items = await selected_items
|
||||
else:
|
||||
selected_items = items[:1]
|
||||
|
||||
tasks = []
|
||||
for index, item in enumerate(selected_items):
|
||||
if item["type"] in {"songs", "library-songs"}:
|
||||
tasks.append(
|
||||
self._get_song_media(
|
||||
media_id=item["id"],
|
||||
index=index,
|
||||
total=len(selected_items),
|
||||
media_metadata=item,
|
||||
)
|
||||
)
|
||||
elif item["type"] in {"albums", "library-albums"}:
|
||||
tasks.append(
|
||||
self._get_album_media(
|
||||
media_id=item["id"],
|
||||
)
|
||||
)
|
||||
else:
|
||||
tasks.append(
|
||||
self._get_music_video_media(
|
||||
media_id=item["id"],
|
||||
index=index,
|
||||
total=len(selected_items),
|
||||
media_metadata=item,
|
||||
)
|
||||
)
|
||||
|
||||
if self.concurrency == 1:
|
||||
for task in tasks:
|
||||
async for media in task:
|
||||
yield media
|
||||
else:
|
||||
collected_tasks = [self._collect_generator(task) for task in tasks]
|
||||
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
|
||||
for batch in batches:
|
||||
for media in batch:
|
||||
yield media
|
||||
|
||||
async def get_media_from_url(
|
||||
self,
|
||||
url: str,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
url_info = self.get_url_info(url)
|
||||
|
||||
if not url_info:
|
||||
raise GamdlInterfaceUrlParseError(url)
|
||||
|
||||
if self.disallowed_media_types and url_info.type in self.disallowed_media_types:
|
||||
raise GamdlInterfaceMediaNotAllowedError(
|
||||
url_info.type,
|
||||
)
|
||||
|
||||
if (
|
||||
url_info.type == "song"
|
||||
or url_info.library_type == "songs"
|
||||
or url_info.sub_id
|
||||
):
|
||||
async for media in self._get_song_media(
|
||||
media_id=url_info.sub_id or url_info.id or url_info.library_id,
|
||||
index=0,
|
||||
total=1,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "music-video" or url_info.library_type == "music-videos":
|
||||
async for media in self._get_music_video_media(
|
||||
media_id=url_info.id or url_info.library_id,
|
||||
index=0,
|
||||
total=1,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "album" or url_info.library_type == "albums":
|
||||
async for media in self._get_album_media(
|
||||
media_id=url_info.id or url_info.library_id,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "playlist" or url_info.library_type == "playlist":
|
||||
async for media in self._get_playlist_media(
|
||||
media_id=url_info.id or url_info.library_id,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "post":
|
||||
async for media in self._get_uploaded_video_media(
|
||||
media_id=url_info.id,
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "artist":
|
||||
async for media in self._get_artist_media(
|
||||
media_id=url_info.id,
|
||||
):
|
||||
yield media
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
import logging
|
||||
import urllib.parse
|
||||
|
||||
import m3u8
|
||||
from async_lru import alru_cache
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
from pywidevine import Cdm
|
||||
|
||||
from ..utils import get_response
|
||||
from .constants import MP4_FORMAT_CODECS
|
||||
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
|
||||
from .interface import AppleMusicInterface
|
||||
from .types import DecryptionKeyAv, MediaFileFormat, MediaTags, StreamInfo, StreamInfoAv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleMusicMusicVideoInterface(AppleMusicInterface):
|
||||
def __init__(self, interface: AppleMusicInterface):
|
||||
self.__dict__.update(interface.__dict__)
|
||||
|
||||
async def get_itunes_page_metadata(
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
) -> dict:
|
||||
alt_id = self.get_alt_id(music_video_metadata)
|
||||
itunes_page = await self.itunes_api.get_itunes_page(
|
||||
"music-video",
|
||||
alt_id,
|
||||
)
|
||||
return itunes_page["storePlatformData"]["product-dv"]["results"][alt_id]
|
||||
|
||||
def get_m3u8_master_url_from_webplayback(self, webplayback: dict) -> str:
|
||||
m3u8_master_url = webplayback["hls-playlist-url"]
|
||||
return m3u8_master_url
|
||||
|
||||
def get_m3u8_master_url_from_itunes_page_metadata(
|
||||
self,
|
||||
itunes_page_metadata: dict,
|
||||
) -> dict:
|
||||
stream_url = itunes_page_metadata["offers"][0]["assets"][0]["hlsUrl"]
|
||||
|
||||
url_parts = urllib.parse.urlparse(stream_url)
|
||||
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
|
||||
query.update({"aec": "HD", "dsid": "1"})
|
||||
|
||||
m3u8_master_url = url_parts._replace(
|
||||
query=urllib.parse.urlencode(query, doseq=True)
|
||||
).geturl()
|
||||
|
||||
return m3u8_master_url
|
||||
|
||||
def get_alt_id(self, metadata: dict) -> str | None:
|
||||
music_video_url = metadata["attributes"].get("url")
|
||||
if music_video_url is None:
|
||||
return None
|
||||
|
||||
alt_id = music_video_url.split("/")[-1].split("?")[0]
|
||||
logger.debug(f"Alt ID: {alt_id}")
|
||||
|
||||
return alt_id
|
||||
|
||||
@alru_cache()
|
||||
async def get_album(
|
||||
self,
|
||||
collection_id: int,
|
||||
) -> dict | None:
|
||||
album_response = await self.apple_music_api.get_album(collection_id)
|
||||
if not album_response:
|
||||
return None
|
||||
return album_response["data"][0]
|
||||
|
||||
async def get_tags(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
) -> MediaTags:
|
||||
alt_id = self.get_alt_id(metadata)
|
||||
lookup_metadata = (await self.itunes_api.get_lookup_result(alt_id))["results"]
|
||||
|
||||
explicitness = lookup_metadata[0]["trackExplicitness"]
|
||||
if explicitness == "notExplicit":
|
||||
rating = MediaRating.NONE
|
||||
elif explicitness == "explicit":
|
||||
rating = MediaRating.EXPLICIT
|
||||
else:
|
||||
rating = MediaRating.CLEAN
|
||||
|
||||
tags = MediaTags(
|
||||
artist=lookup_metadata[0]["artistName"],
|
||||
artist_id=int(lookup_metadata[0]["artistId"]),
|
||||
copyright=itunes_page_metadata.get("copyright"),
|
||||
date=self.parse_date(lookup_metadata[0]["releaseDate"]),
|
||||
genre=lookup_metadata[0]["primaryGenreName"],
|
||||
genre_id=int(itunes_page_metadata["genres"][0]["genreId"]),
|
||||
media_type=MediaType.MUSIC_VIDEO,
|
||||
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
|
||||
title=lookup_metadata[0]["trackCensoredName"],
|
||||
title_id=int(metadata["id"]),
|
||||
rating=rating,
|
||||
)
|
||||
|
||||
if len(lookup_metadata) > 1:
|
||||
album = await self.get_album(itunes_page_metadata["collectionId"])
|
||||
if not album:
|
||||
return tags
|
||||
|
||||
tags.album = lookup_metadata[1]["collectionCensoredName"]
|
||||
tags.album_artist = lookup_metadata[1]["artistName"]
|
||||
tags.album_id = int(itunes_page_metadata["collectionId"])
|
||||
tags.disc = lookup_metadata[0]["discNumber"]
|
||||
tags.disc_total = lookup_metadata[0]["discCount"]
|
||||
tags.compilation = album["attributes"]["isCompilation"]
|
||||
tags.track = lookup_metadata[0]["trackNumber"]
|
||||
tags.track_total = lookup_metadata[0]["trackCount"]
|
||||
|
||||
logger.debug(f"Tags: {tags}")
|
||||
|
||||
return tags
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
codec_priority: list[MusicVideoCodec],
|
||||
resolution: MusicVideoResolution,
|
||||
) -> StreamInfoAv:
|
||||
alt_video_id = self.get_alt_id(metadata)
|
||||
if alt_video_id == metadata["id"]:
|
||||
m3u8_master_url = self.get_m3u8_master_url_from_itunes_page_metadata(
|
||||
itunes_page_metadata,
|
||||
)
|
||||
else:
|
||||
webplayback_response = await self.apple_music_api.get_webplayback(
|
||||
metadata["id"]
|
||||
)
|
||||
m3u8_master_url = self.get_m3u8_master_url_from_webplayback(
|
||||
webplayback_response["songList"][0],
|
||||
)
|
||||
|
||||
playlist_master_m3u8_obj = m3u8.loads(
|
||||
(await get_response(m3u8_master_url)).text
|
||||
)
|
||||
playlist_master_m3u8_obj.base_uri = m3u8_master_url.rpartition("/")[0]
|
||||
stream_info_video = await self.get_stream_info_video(
|
||||
playlist_master_m3u8_obj,
|
||||
codec_priority,
|
||||
resolution,
|
||||
)
|
||||
stream_info_audio = await self.get_stream_info_audio(
|
||||
playlist_master_m3u8_obj.data,
|
||||
codec_priority,
|
||||
)
|
||||
if not stream_info_video or not stream_info_audio:
|
||||
return None
|
||||
|
||||
use_mp4 = any(
|
||||
stream_info_video.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
) or any(
|
||||
stream_info_audio.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
)
|
||||
if use_mp4:
|
||||
file_format = MediaFileFormat.MP4
|
||||
else:
|
||||
file_format = MediaFileFormat.M4V
|
||||
|
||||
stream_info = StreamInfoAv(
|
||||
video_track=stream_info_video,
|
||||
audio_track=stream_info_audio,
|
||||
file_format=file_format,
|
||||
)
|
||||
logger.debug(f"Stream info: {stream_info}")
|
||||
|
||||
return stream_info
|
||||
|
||||
def get_video_playlist_from_resolution(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
codec_priority: list[MusicVideoCodec],
|
||||
resolution: MusicVideoResolution,
|
||||
) -> m3u8.Playlist | None:
|
||||
playlist_results = []
|
||||
for codec_index, codec in enumerate(codec_priority):
|
||||
for playlist in video_playlists:
|
||||
if playlist.stream_info.codecs.startswith(codec.fourcc()):
|
||||
playlist_results.append((codec_index, playlist))
|
||||
|
||||
if not playlist_results:
|
||||
return None
|
||||
|
||||
def sort_key(
|
||||
item: tuple[int, m3u8.Playlist],
|
||||
) -> tuple[bool, int, int, int, int]:
|
||||
codec_index, playlist = item
|
||||
playlist_resolution = playlist.stream_info.resolution[-1]
|
||||
bandwidth = playlist.stream_info.bandwidth
|
||||
exceeds_resolution = playlist_resolution > int(resolution)
|
||||
resolution_difference = abs(playlist_resolution - int(resolution))
|
||||
|
||||
return (
|
||||
exceeds_resolution,
|
||||
resolution_difference,
|
||||
codec_index,
|
||||
-playlist_resolution,
|
||||
-bandwidth,
|
||||
)
|
||||
|
||||
playlist_results.sort(key=sort_key)
|
||||
return playlist_results[0][1]
|
||||
|
||||
def get_best_stereo_audio_playlist(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict | None:
|
||||
audio_playlist = next(
|
||||
(
|
||||
media
|
||||
for media in playlist_master_data["media"]
|
||||
if media["group_id"] == "audio-stereo-256"
|
||||
),
|
||||
None,
|
||||
)
|
||||
return audio_playlist
|
||||
|
||||
async def get_video_playlist_from_user(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
) -> m3u8.Playlist:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
playlist.stream_info.codecs[:4],
|
||||
"x".join(str(v) for v in playlist.stream_info.resolution),
|
||||
str(playlist.stream_info.bandwidth),
|
||||
]
|
||||
),
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in video_playlists
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
async def get_audio_playlist_from_user(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["group_id"],
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlist_master_data["media"]
|
||||
if playlist.get("uri")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which audio codec to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
def _get_key_by_format(
|
||||
self,
|
||||
m3u8_obj: m3u8.M3U8,
|
||||
key_format: str,
|
||||
) -> str:
|
||||
return next(
|
||||
(key for key in m3u8_obj.keys if key.keyformat == key_format),
|
||||
None,
|
||||
).uri
|
||||
|
||||
def get_widevine_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
|
||||
)
|
||||
|
||||
def get_playready_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.microsoft.playready",
|
||||
)
|
||||
|
||||
def get_fairplay_key(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
|
||||
async def get_stream_info_video(
|
||||
self,
|
||||
playlist_master_m3u8_obj: m3u8.M3U8,
|
||||
codec_priority: list[MusicVideoCodec],
|
||||
resolution: MusicVideoResolution,
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in codec_priority:
|
||||
playlist = self.get_video_playlist_from_resolution(
|
||||
playlist_master_m3u8_obj.playlists,
|
||||
codec_priority,
|
||||
resolution,
|
||||
)
|
||||
else:
|
||||
playlist = await self.get_video_playlist_from_user(
|
||||
playlist_master_m3u8_obj.playlists
|
||||
)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist.uri
|
||||
stream_info.codec = playlist.stream_info.codecs
|
||||
stream_info.width, stream_info.height = playlist.stream_info.resolution
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self.get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self.get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self.get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def get_stream_info_audio(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
codec_priority: list[MusicVideoCodec],
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in codec_priority:
|
||||
playlist = self.get_best_stereo_audio_playlist(playlist_master_data)
|
||||
else:
|
||||
playlist = await self.get_audio_playlist_from_user(playlist_master_data)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["group_id"]
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self.get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self.get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self.get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKeyAv:
|
||||
decryption_key_video = await AppleMusicInterface.get_decryption_key(
|
||||
self,
|
||||
stream_info.video_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
cdm,
|
||||
)
|
||||
decryption_key_audio = await AppleMusicInterface.get_decryption_key(
|
||||
self,
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
cdm,
|
||||
)
|
||||
|
||||
return DecryptionKeyAv(
|
||||
video_track=decryption_key_video,
|
||||
audio_track=decryption_key_audio,
|
||||
)
|
||||
@@ -1,500 +0,0 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from xml.dom import minidom
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import m3u8
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
from mutagen.mp4 import MP4
|
||||
from pywidevine import PSSH, Cdm
|
||||
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||
|
||||
from ..utils import get_response
|
||||
from .constants import DRM_DEFAULT_KEY_MAPPING, MP4_FORMAT_CODECS, SONG_CODEC_REGEX_MAP
|
||||
from .enums import MediaRating, MediaType, SongCodec, SyncedLyricsFormat
|
||||
from .interface import AppleMusicInterface
|
||||
from .types import (
|
||||
DecryptionKey,
|
||||
DecryptionKeyAv,
|
||||
Lyrics,
|
||||
MediaFileFormat,
|
||||
MediaTags,
|
||||
StreamInfo,
|
||||
StreamInfoAv,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleMusicSongInterface(AppleMusicInterface):
|
||||
def __init__(self, interface: AppleMusicInterface):
|
||||
self.__dict__.update(interface.__dict__)
|
||||
|
||||
async def get_lyrics(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
synced_lyrics_format: SyncedLyricsFormat,
|
||||
) -> Lyrics | None:
|
||||
if not song_metadata["attributes"]["hasLyrics"]:
|
||||
return None
|
||||
|
||||
if (
|
||||
"relationships" not in song_metadata
|
||||
or "lyrics" not in song_metadata["relationships"]
|
||||
):
|
||||
song_metadata = (
|
||||
await self.apple_music_api.get_song(
|
||||
self.get_media_id_of_library_media(song_metadata)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
if (
|
||||
"lyrics" in song_metadata["relationships"]
|
||||
and "data" in song_metadata["relationships"]["lyrics"]
|
||||
and len(song_metadata["relationships"]["lyrics"]["data"]) > 0
|
||||
and "attributes" in song_metadata["relationships"]["lyrics"]["data"][0]
|
||||
and song_metadata["relationships"]["lyrics"]["data"][0]["attributes"].get(
|
||||
"ttml"
|
||||
)
|
||||
is not None
|
||||
):
|
||||
lyrics = self._get_lyrics(
|
||||
song_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
|
||||
"ttml"
|
||||
],
|
||||
synced_lyrics_format,
|
||||
)
|
||||
logging.debug(f"Lyrics: {lyrics}")
|
||||
|
||||
return lyrics
|
||||
|
||||
def _get_lyrics(
|
||||
self,
|
||||
lyrics_ttml: str,
|
||||
synced_lyrics_format: SyncedLyricsFormat,
|
||||
) -> Lyrics:
|
||||
lyrics_ttml_et = ElementTree.fromstring(lyrics_ttml)
|
||||
unsynced_lyrics = []
|
||||
synced_lyrics = []
|
||||
index = 1
|
||||
|
||||
for div in lyrics_ttml_et.iter("{http://www.w3.org/ns/ttml}div"):
|
||||
stanza = []
|
||||
unsynced_lyrics.append(stanza)
|
||||
|
||||
for p in div.iter("{http://www.w3.org/ns/ttml}p"):
|
||||
if p.text is not None:
|
||||
stanza.append(p.text)
|
||||
|
||||
if p.attrib.get("begin"):
|
||||
if synced_lyrics_format == SyncedLyricsFormat.LRC:
|
||||
synced_lyrics.append(self._get_lyrics_line_lrc(p))
|
||||
|
||||
if synced_lyrics_format == SyncedLyricsFormat.SRT:
|
||||
synced_lyrics.append(self._get_lyrics_line_srt(index, p))
|
||||
|
||||
if synced_lyrics_format == SyncedLyricsFormat.TTML:
|
||||
if not synced_lyrics:
|
||||
synced_lyrics.append(
|
||||
minidom.parseString(lyrics_ttml).toprettyxml()
|
||||
)
|
||||
continue
|
||||
|
||||
index += 1
|
||||
|
||||
return 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
|
||||
),
|
||||
)
|
||||
|
||||
def _parse_ttml_timestamp(
|
||||
self,
|
||||
timestamp_ttml: str,
|
||||
) -> datetime.datetime:
|
||||
mins_secs_ms = re.findall(r"\d+", timestamp_ttml)
|
||||
ms, secs, mins = 0, 0, 0
|
||||
|
||||
if len(mins_secs_ms) == 2 and ":" in timestamp_ttml:
|
||||
secs, mins = int(mins_secs_ms[-1]), int(mins_secs_ms[-2])
|
||||
|
||||
elif len(mins_secs_ms) == 1:
|
||||
ms = int(mins_secs_ms[-1])
|
||||
|
||||
else:
|
||||
secs = float(f"{mins_secs_ms[-2]}.{mins_secs_ms[-1]}")
|
||||
if len(mins_secs_ms) > 2:
|
||||
mins = int(mins_secs_ms[-3])
|
||||
|
||||
return datetime.datetime.fromtimestamp(
|
||||
(mins * 60) + secs + (ms / 1000),
|
||||
tz=datetime.timezone.utc,
|
||||
)
|
||||
|
||||
def _get_lyrics_line_srt(self, index: int, element: ElementTree.Element) -> str:
|
||||
timestamp_begin_ttml = element.attrib.get("begin")
|
||||
timestamp_end_ttml = element.attrib.get("end")
|
||||
text = element.text
|
||||
|
||||
timestamp_begin = self._parse_ttml_timestamp(timestamp_begin_ttml)
|
||||
timestamp_end = self._parse_ttml_timestamp(timestamp_end_ttml)
|
||||
|
||||
return (
|
||||
f"{index}\n"
|
||||
f"{timestamp_begin.strftime('%H:%M:%S,%f')[:-3]} --> "
|
||||
f"{timestamp_end.strftime('%H:%M:%S,%f')[:-3]}\n"
|
||||
f"{text}\n"
|
||||
)
|
||||
|
||||
def _get_lyrics_line_lrc(self, element: ElementTree.Element) -> str:
|
||||
timestamp_ttml = element.attrib.get("begin")
|
||||
text = element.text
|
||||
|
||||
timestamp = self._parse_ttml_timestamp(timestamp_ttml)
|
||||
ms_new = timestamp.strftime("%f")[:-3]
|
||||
|
||||
if int(ms_new[-1]) >= 5:
|
||||
ms = int(f"{int(ms_new[:2]) + 1}") * 10
|
||||
timestamp += datetime.timedelta(milliseconds=ms) - datetime.timedelta(
|
||||
microseconds=timestamp.microsecond
|
||||
)
|
||||
|
||||
return f"[{timestamp.strftime('%M:%S.%f')[:-4]}]{text}"
|
||||
|
||||
async def get_tags(
|
||||
self,
|
||||
webplayback: dict,
|
||||
lyrics: str | None = None,
|
||||
use_album_date: bool = False,
|
||||
) -> MediaTags:
|
||||
webplayback_metadata = webplayback["songList"][0]["assets"][0]["metadata"]
|
||||
|
||||
tags = MediaTags(
|
||||
album=webplayback_metadata["playlistName"],
|
||||
album_artist=webplayback_metadata["playlistArtistName"],
|
||||
album_id=int(webplayback_metadata["playlistId"]),
|
||||
album_sort=webplayback_metadata["sort-album"],
|
||||
artist=webplayback_metadata["artistName"],
|
||||
artist_id=int(webplayback_metadata["artistId"]),
|
||||
artist_sort=webplayback_metadata["sort-artist"],
|
||||
comment=webplayback_metadata.get("comments"),
|
||||
compilation=webplayback_metadata["compilation"],
|
||||
composer=webplayback_metadata.get("composerName"),
|
||||
composer_id=(
|
||||
int(webplayback_metadata.get("composerId"))
|
||||
if webplayback_metadata.get("composerId")
|
||||
else None
|
||||
),
|
||||
composer_sort=webplayback_metadata.get("sort-composer"),
|
||||
copyright=webplayback_metadata.get("copyright"),
|
||||
date=(
|
||||
await self.get_media_date(webplayback_metadata["playlistId"])
|
||||
if use_album_date
|
||||
else (
|
||||
self.parse_date(webplayback_metadata["releaseDate"])
|
||||
if webplayback_metadata.get("releaseDate")
|
||||
else None
|
||||
)
|
||||
),
|
||||
disc=webplayback_metadata["discNumber"],
|
||||
disc_total=webplayback_metadata["discCount"],
|
||||
gapless=webplayback_metadata["gapless"],
|
||||
genre=webplayback_metadata.get("genre"),
|
||||
genre_id=int(webplayback_metadata["genreId"]),
|
||||
lyrics=lyrics if lyrics else None,
|
||||
media_type=MediaType.SONG,
|
||||
rating=MediaRating(webplayback_metadata["explicit"]),
|
||||
storefront=webplayback_metadata["s"],
|
||||
title=webplayback_metadata["itemName"],
|
||||
title_id=int(webplayback_metadata["itemId"]),
|
||||
title_sort=webplayback_metadata["sort-name"],
|
||||
track=webplayback_metadata["trackNumber"],
|
||||
track_total=webplayback_metadata["trackCount"],
|
||||
xid=webplayback_metadata.get("xid"),
|
||||
)
|
||||
logger.debug(f"Tags: {tags}")
|
||||
|
||||
return tags
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
codec: SongCodec,
|
||||
song_metadata: dict | None = None,
|
||||
webplayback: dict | None = None,
|
||||
) -> StreamInfoAv | None:
|
||||
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:
|
||||
if "extendedAssetUrls" not in song_metadata["attributes"]:
|
||||
song_metadata = (
|
||||
await self.apple_music_api.get_song(
|
||||
self.get_media_id_of_library_media(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(m3u8_master_url)).text)
|
||||
m3u8_master_data = m3u8_master_obj.data
|
||||
|
||||
if codec == SongCodec.ASK:
|
||||
playlist = await self._get_playlist_from_user(m3u8_master_data)
|
||||
else:
|
||||
playlist = self._get_playlist_from_codec(
|
||||
m3u8_master_data,
|
||||
codec,
|
||||
)
|
||||
|
||||
if playlist is None:
|
||||
return None
|
||||
|
||||
stream_info = StreamInfo(legacy=False)
|
||||
stream_info.stream_url = (
|
||||
f"{m3u8_master_url.rpartition('/')[0]}/{playlist['uri']}"
|
||||
)
|
||||
stream_info.codec = playlist["stream_info"]["codecs"]
|
||||
is_mp4 = any(stream_info.codec.startswith(codec) for codec in MP4_FORMAT_CODECS)
|
||||
|
||||
session_key_metadata = self._get_audio_session_key_metadata(m3u8_master_data)
|
||||
|
||||
if session_key_metadata:
|
||||
asset_metadata = self._get_asset_metadata(m3u8_master_data)
|
||||
variant_id = playlist["stream_info"]["stable_variant_id"]
|
||||
drm_ids = asset_metadata[variant_id]["AUDIO-SESSION-KEY-IDS"]
|
||||
|
||||
stream_info.widevine_pssh = self._get_drm_uri_from_session_key(
|
||||
session_key_metadata,
|
||||
drm_ids,
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
|
||||
)
|
||||
stream_info.playready_pssh = self._get_drm_uri_from_session_key(
|
||||
session_key_metadata,
|
||||
drm_ids,
|
||||
"com.microsoft.playready",
|
||||
)
|
||||
stream_info.fairplay_key = self._get_drm_uri_from_session_key(
|
||||
session_key_metadata,
|
||||
drm_ids,
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
else:
|
||||
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
|
||||
|
||||
stream_info.widevine_pssh = self._get_drm_uri_from_m3u8_keys(
|
||||
m3u8_obj,
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
|
||||
)
|
||||
stream_info.playready_pssh = self._get_drm_uri_from_m3u8_keys(
|
||||
m3u8_obj,
|
||||
"com.microsoft.playready",
|
||||
)
|
||||
stream_info.fairplay_key = self._get_drm_uri_from_m3u8_keys(
|
||||
m3u8_obj,
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
|
||||
stream_info_av = StreamInfoAv(
|
||||
audio_track=stream_info,
|
||||
file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A,
|
||||
)
|
||||
logger.debug(f"Stream info: {stream_info_av}")
|
||||
|
||||
return stream_info_av
|
||||
|
||||
def _get_m3u8_metadata(self, m3u8_data: dict, data_id: str) -> dict | None:
|
||||
for session_data in m3u8_data.get("session_data", []):
|
||||
if session_data["data_id"] == data_id:
|
||||
return json.loads(
|
||||
base64.b64decode(session_data["value"]).decode("utf-8")
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_audio_session_key_metadata(self, m3u8_data: dict) -> dict | None:
|
||||
return self._get_m3u8_metadata(
|
||||
m3u8_data,
|
||||
"com.apple.hls.AudioSessionKeyInfo",
|
||||
)
|
||||
|
||||
def _get_asset_metadata(self, m3u8_data: dict) -> dict | None:
|
||||
return self._get_m3u8_metadata(
|
||||
m3u8_data,
|
||||
"com.apple.hls.audioAssetMetadata",
|
||||
)
|
||||
|
||||
def _get_playlist_from_codec(
|
||||
self, m3u8_data: dict, codec: SongCodec
|
||||
) -> dict | None:
|
||||
matching_playlists = [
|
||||
playlist
|
||||
for playlist in m3u8_data["playlists"]
|
||||
if re.fullmatch(
|
||||
SONG_CODEC_REGEX_MAP[codec.value], playlist["stream_info"]["audio"]
|
||||
)
|
||||
]
|
||||
|
||||
if not matching_playlists:
|
||||
return None
|
||||
|
||||
return max(
|
||||
matching_playlists,
|
||||
key=lambda x: x["stream_info"]["average_bandwidth"],
|
||||
)
|
||||
|
||||
async def _get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["stream_info"]["audio"],
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in m3u8_data["playlists"]
|
||||
]
|
||||
|
||||
return await inquirer.select(
|
||||
message="Select which codec to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
def _get_drm_uri_from_session_key(
|
||||
self,
|
||||
drm_infos: dict,
|
||||
drm_ids: list,
|
||||
drm_key: str,
|
||||
) -> str | None:
|
||||
for drm_id in drm_ids:
|
||||
if drm_id != "1" and drm_key in drm_infos.get(drm_id, {}):
|
||||
return drm_infos[drm_id][drm_key]["URI"]
|
||||
return None
|
||||
|
||||
def _get_drm_uri_from_m3u8_keys(
|
||||
self,
|
||||
m3u8_obj: m3u8.M3U8,
|
||||
drm_key: str,
|
||||
) -> str | None:
|
||||
default_uri = DRM_DEFAULT_KEY_MAPPING[drm_key]
|
||||
|
||||
for key in m3u8_obj.keys:
|
||||
if key.keyformat == drm_key and key.uri != default_uri:
|
||||
return key.uri
|
||||
return None
|
||||
|
||||
async def _get_stream_info_legacy(
|
||||
self,
|
||||
webplayback: dict,
|
||||
codec: SongCodec,
|
||||
) -> StreamInfoAv:
|
||||
flavor = "32:ctrp64" if codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
|
||||
|
||||
stream_info = StreamInfo(legacy=True)
|
||||
stream_info.stream_url = next(
|
||||
i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor
|
||||
)["URL"]
|
||||
|
||||
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
|
||||
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
|
||||
|
||||
stream_info_av = StreamInfoAv(
|
||||
media_id=webplayback["songList"][0]["songId"],
|
||||
audio_track=stream_info,
|
||||
file_format=MediaFileFormat.M4A,
|
||||
)
|
||||
logger.debug(f"Stream info legacy: {stream_info_av}")
|
||||
|
||||
return stream_info_av
|
||||
|
||||
async def get_decryption_key_legacy(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKeyAv:
|
||||
stream_info_audio = stream_info.audio_track
|
||||
|
||||
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])
|
||||
)
|
||||
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
|
||||
|
||||
challenge = base64.b64encode(
|
||||
await asyncio.to_thread(
|
||||
cdm.get_license_challenge, cdm_session, pssh_obj
|
||||
)
|
||||
).decode()
|
||||
license_response = await self.apple_music_api.get_license_exchange(
|
||||
stream_info.media_id,
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
challenge,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(
|
||||
cdm.parse_license, cdm_session, license_response["license"]
|
||||
)
|
||||
|
||||
decryption_key = next(
|
||||
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
)
|
||||
finally:
|
||||
cdm.close(cdm_session)
|
||||
|
||||
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 AppleMusicInterface.get_decryption_key(
|
||||
self,
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
cdm,
|
||||
)
|
||||
)
|
||||
|
||||
async def get_extra_tags(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
) -> dict:
|
||||
previews = song_metadata["attributes"].get("previews", [])
|
||||
if not previews:
|
||||
return {}
|
||||
|
||||
preview_url = previews[0]["url"]
|
||||
preview_response = await get_response(preview_url)
|
||||
preview_bytes = preview_response.content
|
||||
preview_tags = dict(MP4(io.BytesIO(preview_bytes)).tags)
|
||||
|
||||
logger.debug(f"Extra tags: {preview_tags.keys()}")
|
||||
return preview_tags
|
||||
@@ -1,86 +0,0 @@
|
||||
import logging
|
||||
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from ..interface.enums import UploadedVideoQuality
|
||||
from ..interface.types import MediaTags
|
||||
from .constants import UPLOADED_VIDEO_QUALITY_RANK
|
||||
from .interface import AppleMusicInterface
|
||||
from .types import MediaFileFormat, StreamInfo, StreamInfoAv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoInterface(AppleMusicInterface):
|
||||
def __init__(self, interface: AppleMusicInterface):
|
||||
self.__dict__.update(interface.__dict__)
|
||||
|
||||
def get_stream_url_best(self, metadata: dict) -> str:
|
||||
best_quality = next(
|
||||
(
|
||||
quality
|
||||
for quality in UPLOADED_VIDEO_QUALITY_RANK
|
||||
if metadata["attributes"]["assetTokens"].get(quality)
|
||||
),
|
||||
None,
|
||||
)
|
||||
return metadata["attributes"]["assetTokens"][best_quality]
|
||||
|
||||
async def get_stream_url_from_user(self, metadata: dict) -> str:
|
||||
qualities = list(metadata["attributes"]["assetTokens"].keys())
|
||||
choices = [
|
||||
Choice(
|
||||
name=quality,
|
||||
value=quality,
|
||||
)
|
||||
for quality in qualities
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which quality to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return metadata["attributes"]["assetTokens"][selected]
|
||||
|
||||
async def get_stream_url(
|
||||
self, metadata: dict, quality: UploadedVideoQuality
|
||||
) -> str:
|
||||
if quality == UploadedVideoQuality.BEST:
|
||||
stream_url = self.get_stream_url_best(metadata)
|
||||
|
||||
if quality == UploadedVideoQuality.ASK:
|
||||
stream_url = await self.get_stream_url_from_user(metadata)
|
||||
|
||||
logger.debug(f"Stream URL: {stream_url}")
|
||||
|
||||
return stream_url
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
metadata: dict,
|
||||
quality: UploadedVideoQuality,
|
||||
) -> StreamInfo:
|
||||
stream_url = await self.get_stream_url(metadata, quality)
|
||||
stream_info = StreamInfoAv(
|
||||
file_format=MediaFileFormat.M4V,
|
||||
video_track=StreamInfo(
|
||||
stream_url=stream_url,
|
||||
),
|
||||
)
|
||||
return stream_info
|
||||
|
||||
def get_tags(self, metadata: dict) -> MediaTags:
|
||||
attributes = metadata["attributes"]
|
||||
upload_date = attributes.get("uploadDate")
|
||||
|
||||
tags = MediaTags(
|
||||
artist=attributes.get("artistName"),
|
||||
date=self.parse_date(upload_date) if upload_date else None,
|
||||
title=attributes.get("name"),
|
||||
title_id=int(metadata["id"]),
|
||||
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
|
||||
)
|
||||
logger.debug(f"Tags: {tags}")
|
||||
|
||||
return tags
|
||||
@@ -0,0 +1,462 @@
|
||||
import asyncio
|
||||
import urllib.parse
|
||||
from typing import AsyncGenerator, Callable
|
||||
|
||||
import m3u8
|
||||
import structlog
|
||||
|
||||
from .base import AppleMusicBaseInterface
|
||||
from .constants import MP4_FORMAT_CODECS
|
||||
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
|
||||
from .exceptions import (
|
||||
GamdlInterfaceDecryptionNotAvailableError,
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
)
|
||||
from .types import (
|
||||
AppleMusicMedia,
|
||||
DecryptionKeyAv,
|
||||
MediaFileFormat,
|
||||
MediaTags,
|
||||
StreamInfo,
|
||||
StreamInfoAv,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicMusicVideoInterface:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseInterface,
|
||||
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
|
||||
codec_priority: list[MusicVideoCodec] = [
|
||||
MusicVideoCodec.H264,
|
||||
MusicVideoCodec.H265,
|
||||
],
|
||||
ask_video_codec_function: (
|
||||
Callable[[list[m3u8.Playlist]], dict | None] | None
|
||||
) = None,
|
||||
ask_audio_codec_function: Callable[[list[dict]], dict | None] | None = None,
|
||||
):
|
||||
self.base = base
|
||||
self.resolution = resolution
|
||||
self.codec_priority = codec_priority
|
||||
self.ask_video_codec_function = ask_video_codec_function
|
||||
self.ask_audio_codec_function = ask_audio_codec_function
|
||||
|
||||
async def get_itunes_page_metadata(
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
) -> dict:
|
||||
url_media_id = self.base.parse_media_id_from_url(music_video_metadata)
|
||||
itunes_page = await self.base.itunes_api.get_itunes_page(
|
||||
"music-video",
|
||||
url_media_id,
|
||||
)
|
||||
return itunes_page["storePlatformData"]["product-dv"]["results"][url_media_id]
|
||||
|
||||
def _get_m3u8_master_url_from_webplayback(self, webplayback: dict) -> str:
|
||||
m3u8_master_url = webplayback["hls-playlist-url"]
|
||||
return m3u8_master_url
|
||||
|
||||
def _get_m3u8_master_url_from_itunes_page_metadata(
|
||||
self,
|
||||
itunes_page_metadata: dict,
|
||||
) -> str | None:
|
||||
log = logger.bind(action="get_m3u8_master_url_from_itunes_page_metadata")
|
||||
|
||||
stream_url = itunes_page_metadata["offers"][0]["assets"][0].get("hlsUrl")
|
||||
if not stream_url:
|
||||
return None
|
||||
|
||||
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()
|
||||
|
||||
m3u8_master_url = m3u8_master_url.replace(
|
||||
"play-edge.itunes.apple.com",
|
||||
"play.itunes.apple.com",
|
||||
).replace(
|
||||
"MZPlayLocal.woa",
|
||||
"MZPlay.woa",
|
||||
)
|
||||
|
||||
log.debug("success", m3u8_master_url=m3u8_master_url)
|
||||
|
||||
return m3u8_master_url
|
||||
|
||||
async def get_tags(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
) -> MediaTags:
|
||||
log = logger.bind(
|
||||
action="get_music_video_tags",
|
||||
media_id=metadata["id"],
|
||||
)
|
||||
|
||||
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=metadata["id"],
|
||||
)
|
||||
|
||||
url_media_id = self.base.parse_media_id_from_url(metadata)
|
||||
m3u8_master_url = None
|
||||
|
||||
if url_media_id == metadata["id"]:
|
||||
m3u8_master_url = self._get_m3u8_master_url_from_itunes_page_metadata(
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
if not m3u8_master_url:
|
||||
webplayback_response = await self.base.apple_music_api.get_webplayback(
|
||||
metadata["id"]
|
||||
)
|
||||
m3u8_master_url = self._get_m3u8_master_url_from_webplayback(
|
||||
webplayback_response["songList"][0],
|
||||
)
|
||||
|
||||
playlist_master_m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(m3u8_master_url)).text
|
||||
)
|
||||
playlist_master_m3u8_obj.base_uri = m3u8_master_url.rpartition("/")[0]
|
||||
stream_info_video = await self._get_stream_info_video(playlist_master_m3u8_obj)
|
||||
stream_info_audio = await self._get_stream_info_audio(
|
||||
playlist_master_m3u8_obj.data,
|
||||
)
|
||||
if not stream_info_video or not stream_info_audio:
|
||||
return None
|
||||
|
||||
use_mp4 = any(
|
||||
stream_info_video.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
) or any(
|
||||
stream_info_audio.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
)
|
||||
if use_mp4:
|
||||
file_format = MediaFileFormat.MP4
|
||||
else:
|
||||
file_format = MediaFileFormat.M4V
|
||||
|
||||
stream_info = StreamInfoAv(
|
||||
video_track=stream_info_video,
|
||||
audio_track=stream_info_audio,
|
||||
file_format=file_format,
|
||||
)
|
||||
|
||||
log.debug("success", stream_info=stream_info)
|
||||
|
||||
return stream_info
|
||||
|
||||
def _get_video_playlist_from_resolution(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
) -> m3u8.Playlist | None:
|
||||
playlist_results = []
|
||||
for codec_index, codec in enumerate(self.codec_priority):
|
||||
for playlist in video_playlists:
|
||||
if playlist.stream_info.codecs.startswith(codec.fourcc()):
|
||||
playlist_results.append((codec_index, playlist))
|
||||
|
||||
if not playlist_results:
|
||||
return None
|
||||
|
||||
def sort_key(
|
||||
item: tuple[int, m3u8.Playlist],
|
||||
) -> tuple[bool, int, int, int, int]:
|
||||
codec_index, playlist = item
|
||||
playlist_resolution = playlist.stream_info.resolution[-1]
|
||||
bandwidth = playlist.stream_info.bandwidth
|
||||
exceeds_resolution = playlist_resolution > int(self.resolution)
|
||||
resolution_difference = abs(playlist_resolution - int(self.resolution))
|
||||
|
||||
return (
|
||||
exceeds_resolution,
|
||||
resolution_difference,
|
||||
codec_index,
|
||||
-playlist_resolution,
|
||||
-bandwidth,
|
||||
)
|
||||
|
||||
playlist_results.sort(key=sort_key)
|
||||
return playlist_results[0][1]
|
||||
|
||||
def _get_best_stereo_audio_playlist(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict | None:
|
||||
audio_playlist = next(
|
||||
(
|
||||
media
|
||||
for media in playlist_master_data["media"]
|
||||
if media["group_id"] == "audio-stereo-256"
|
||||
),
|
||||
None,
|
||||
)
|
||||
return audio_playlist
|
||||
|
||||
async def _get_video_playlist_from_user(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
) -> m3u8.Playlist | None:
|
||||
if self.ask_video_codec_function:
|
||||
video_playlist = self.ask_video_codec_function(video_playlists)
|
||||
if asyncio.iscoroutine(video_playlist):
|
||||
video_playlist = await video_playlist
|
||||
|
||||
return video_playlist
|
||||
|
||||
return None
|
||||
|
||||
async def _get_audio_playlist_from_user(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict | None:
|
||||
if self.ask_audio_codec_function:
|
||||
audio_playlist = self.ask_audio_codec_function(
|
||||
[
|
||||
playlist
|
||||
for playlist in playlist_master_data["media"]
|
||||
if playlist.get("uri")
|
||||
]
|
||||
)
|
||||
if asyncio.iscoroutine(audio_playlist):
|
||||
audio_playlist = await audio_playlist
|
||||
|
||||
return audio_playlist
|
||||
|
||||
return None
|
||||
|
||||
def _get_key_by_format(
|
||||
self,
|
||||
m3u8_obj: m3u8.M3U8,
|
||||
key_format: str,
|
||||
) -> str:
|
||||
return next(
|
||||
(key for key in m3u8_obj.keys if key.keyformat == key_format),
|
||||
None,
|
||||
).uri
|
||||
|
||||
def _get_widevine_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
|
||||
)
|
||||
|
||||
def _get_playready_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.microsoft.playready",
|
||||
)
|
||||
|
||||
def _get_fairplay_key(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
|
||||
async def _get_stream_info_video(
|
||||
self,
|
||||
playlist_master_m3u8_obj: m3u8.M3U8,
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in self.codec_priority:
|
||||
playlist = self._get_video_playlist_from_resolution(
|
||||
playlist_master_m3u8_obj.playlists,
|
||||
)
|
||||
else:
|
||||
playlist = await self._get_video_playlist_from_user(
|
||||
playlist_master_m3u8_obj.playlists
|
||||
)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist.uri
|
||||
stream_info.codec = playlist.stream_info.codecs
|
||||
stream_info.width, stream_info.height = playlist.stream_info.resolution
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self._get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self._get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self._get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def _get_stream_info_audio(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in self.codec_priority:
|
||||
playlist = self._get_best_stereo_audio_playlist(playlist_master_data)
|
||||
else:
|
||||
playlist = await self._get_audio_playlist_from_user(playlist_master_data)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["group_id"]
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self._get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self._get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self._get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
) -> DecryptionKeyAv:
|
||||
decryption_key_video, decryption_key_audio = await asyncio.gather(
|
||||
self.base.get_decryption_key(
|
||||
stream_info.video_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
),
|
||||
self.base.get_decryption_key(
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
),
|
||||
)
|
||||
|
||||
return DecryptionKeyAv(
|
||||
video_track=decryption_key_video,
|
||||
audio_track=decryption_key_audio,
|
||||
)
|
||||
|
||||
async def get_media(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
if not media.media_metadata:
|
||||
media.media_metadata = (
|
||||
await (
|
||||
self.base.apple_music_api.get_library_music_video(media.media_id)
|
||||
if media.is_library
|
||||
else self.base.apple_music_api.get_music_video(media.media_id)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
if media.media_metadata["attributes"]["playParams"].get("isLibrary"):
|
||||
catalog_metadata = self.base.get_catalog_metadata_from_library(
|
||||
media.media_metadata
|
||||
)
|
||||
if catalog_metadata:
|
||||
media.media_id = catalog_metadata["id"]
|
||||
media.is_library = False
|
||||
media.media_metadata = catalog_metadata
|
||||
|
||||
if media.is_library:
|
||||
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
|
||||
|
||||
yield media
|
||||
|
||||
if not self.base.is_media_streamable(media.media_metadata):
|
||||
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
|
||||
|
||||
if media.playlist_metadata:
|
||||
media.playlist_tags = self.base.get_playlist_tags(
|
||||
media.playlist_metadata,
|
||||
media.index,
|
||||
)
|
||||
|
||||
media.cover = await self.base.get_cover(media.media_metadata)
|
||||
|
||||
itunes_page_metadata = await self.get_itunes_page_metadata(media.media_metadata)
|
||||
|
||||
if self.base.wrapper_api:
|
||||
playback = await self.base.wrapper_api.get_playback(media.media_id)
|
||||
media.tags = await self.base.get_tags_from_asset_info(
|
||||
playback["songList"][0]["assets"][0]["metadata"],
|
||||
)
|
||||
else:
|
||||
media.tags = await self.get_tags(
|
||||
media.media_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
media.stream_info = await self.get_stream_info(
|
||||
media.media_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
if not media.stream_info:
|
||||
raise GamdlInterfaceFormatNotAvailableError(
|
||||
media.media_id,
|
||||
self.codec_priority,
|
||||
)
|
||||
|
||||
if (
|
||||
not media.stream_info.video_track.widevine_pssh
|
||||
or not media.stream_info.audio_track.widevine_pssh
|
||||
):
|
||||
raise GamdlInterfaceDecryptionNotAvailableError(media.media_id)
|
||||
|
||||
media.decryption_key = await self.get_decryption_key(media.stream_info)
|
||||
|
||||
media.partial = False
|
||||
|
||||
yield media
|
||||
@@ -0,0 +1,595 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
from typing import AsyncGenerator, Callable
|
||||
from xml.dom import minidom
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import m3u8
|
||||
import structlog
|
||||
|
||||
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 .exceptions import (
|
||||
GamdlInterfaceDecryptionNotAvailableError,
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
)
|
||||
from .types import (
|
||||
AppleMusicMedia,
|
||||
DecryptionKeyAv,
|
||||
Lyrics,
|
||||
MediaFileFormat,
|
||||
MediaTags,
|
||||
StreamInfo,
|
||||
StreamInfoAv,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicSongInterface:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseInterface,
|
||||
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
|
||||
codec_priority: list[SongCodec] = [SongCodec.AAC_WEB],
|
||||
use_album_date: bool = False,
|
||||
skip_stream_info: bool = False,
|
||||
ask_codec_function: Callable[[list[dict]], dict | None] | None = None,
|
||||
):
|
||||
self.base = base
|
||||
self.synced_lyrics_format = synced_lyrics_format
|
||||
self.codec_priority = codec_priority
|
||||
self.use_album_date = use_album_date
|
||||
self.skip_stream_info = skip_stream_info
|
||||
self.ask_codec_function = ask_codec_function
|
||||
|
||||
async def get_lyrics(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
) -> Lyrics | None:
|
||||
log = logger.bind(
|
||||
action="get_lyrics",
|
||||
song_id=song_metadata["id"],
|
||||
)
|
||||
|
||||
if song_metadata["attributes"]["playParams"].get("isLibrary"):
|
||||
log.debug("library_song_no_lyrics")
|
||||
return None
|
||||
|
||||
if not song_metadata["attributes"]["hasLyrics"]:
|
||||
log.debug("no_lyrics")
|
||||
return None
|
||||
|
||||
if (
|
||||
"relationships" not in song_metadata
|
||||
or "lyrics" not in song_metadata["relationships"]
|
||||
):
|
||||
song_metadata = (
|
||||
await self.base.apple_music_api.get_song(
|
||||
song_metadata["id"],
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
if (
|
||||
"lyrics" in song_metadata["relationships"]
|
||||
and "data" in song_metadata["relationships"]["lyrics"]
|
||||
and len(song_metadata["relationships"]["lyrics"]["data"]) > 0
|
||||
and "attributes" in song_metadata["relationships"]["lyrics"]["data"][0]
|
||||
and song_metadata["relationships"]["lyrics"]["data"][0]["attributes"].get(
|
||||
"ttml"
|
||||
)
|
||||
is not None
|
||||
):
|
||||
lyrics = self._get_lyrics(
|
||||
song_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
|
||||
"ttml"
|
||||
],
|
||||
)
|
||||
|
||||
log.debug("success", lyrics=lyrics)
|
||||
|
||||
return lyrics
|
||||
else:
|
||||
log.debug("no_lyrics_data")
|
||||
|
||||
def _get_lyrics(
|
||||
self,
|
||||
lyrics_ttml: str,
|
||||
) -> Lyrics:
|
||||
lyrics_ttml_et = ElementTree.fromstring(lyrics_ttml)
|
||||
unsynced_lyrics = []
|
||||
synced_lyrics = []
|
||||
index = 1
|
||||
|
||||
for div in lyrics_ttml_et.iter("{http://www.w3.org/ns/ttml}div"):
|
||||
stanza = []
|
||||
unsynced_lyrics.append(stanza)
|
||||
|
||||
for p in div.iter("{http://www.w3.org/ns/ttml}p"):
|
||||
if p.text is not None:
|
||||
stanza.append(p.text)
|
||||
|
||||
if p.attrib.get("begin"):
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.LRC:
|
||||
synced_lyrics.append(self._get_lyrics_line_lrc(p))
|
||||
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.SRT:
|
||||
synced_lyrics.append(self._get_lyrics_line_srt(index, p))
|
||||
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.TTML:
|
||||
if not synced_lyrics:
|
||||
synced_lyrics.append(
|
||||
minidom.parseString(lyrics_ttml).toprettyxml()
|
||||
)
|
||||
continue
|
||||
|
||||
index += 1
|
||||
|
||||
return 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
|
||||
),
|
||||
)
|
||||
|
||||
def _parse_ttml_timestamp(
|
||||
self,
|
||||
timestamp_ttml: str,
|
||||
) -> datetime.datetime:
|
||||
mins_secs_ms = re.findall(r"\d+", timestamp_ttml)
|
||||
ms, secs, mins = 0, 0, 0
|
||||
|
||||
if len(mins_secs_ms) == 2 and ":" in timestamp_ttml:
|
||||
secs, mins = int(mins_secs_ms[-1]), int(mins_secs_ms[-2])
|
||||
|
||||
elif len(mins_secs_ms) == 1:
|
||||
ms = int(mins_secs_ms[-1])
|
||||
|
||||
else:
|
||||
secs = float(f"{mins_secs_ms[-2]}.{mins_secs_ms[-1]}")
|
||||
if len(mins_secs_ms) > 2:
|
||||
mins = int(mins_secs_ms[-3])
|
||||
|
||||
return datetime.datetime.fromtimestamp(
|
||||
(mins * 60) + secs + (ms / 1000),
|
||||
tz=datetime.timezone.utc,
|
||||
)
|
||||
|
||||
def _get_lyrics_line_srt(self, index: int, element: ElementTree.Element) -> str:
|
||||
timestamp_begin_ttml = element.attrib.get("begin")
|
||||
timestamp_end_ttml = element.attrib.get("end")
|
||||
text = element.text
|
||||
|
||||
timestamp_begin = self._parse_ttml_timestamp(timestamp_begin_ttml)
|
||||
timestamp_end = self._parse_ttml_timestamp(timestamp_end_ttml)
|
||||
|
||||
return (
|
||||
f"{index}\n"
|
||||
f"{timestamp_begin.strftime('%H:%M:%S,%f')[:-3]} --> "
|
||||
f"{timestamp_end.strftime('%H:%M:%S,%f')[:-3]}\n"
|
||||
f"{text}\n"
|
||||
)
|
||||
|
||||
def _get_lyrics_line_lrc(self, element: ElementTree.Element) -> str:
|
||||
timestamp_ttml = element.attrib.get("begin")
|
||||
text = element.text
|
||||
|
||||
timestamp = self._parse_ttml_timestamp(timestamp_ttml)
|
||||
ms_new = timestamp.strftime("%f")[:-3]
|
||||
|
||||
if int(ms_new[-1]) >= 5:
|
||||
ms = int(f"{int(ms_new[:2]) + 1}") * 10
|
||||
timestamp += datetime.timedelta(milliseconds=ms) - datetime.timedelta(
|
||||
microseconds=timestamp.microsecond
|
||||
)
|
||||
|
||||
return f"[{timestamp.strftime('%M:%S.%f')[:-4]}]{text}"
|
||||
|
||||
def _get_m3u8_from_playback(self, playback: dict) -> str | None:
|
||||
return playback["songList"][0].get("hls-playlist-url")
|
||||
|
||||
async def get_m3u8_master_url(
|
||||
self,
|
||||
playback: dict | None,
|
||||
song_metadata: dict | None,
|
||||
) -> str | None:
|
||||
if playback:
|
||||
return self._get_m3u8_from_playback(playback)
|
||||
else:
|
||||
return await self._get_m3u8_master_url_from_metadata(song_metadata)
|
||||
|
||||
async def _get_m3u8_master_url_from_metadata(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
) -> str | None:
|
||||
log = logger.bind(
|
||||
action="get_m3u8_master_url_from_metadata",
|
||||
song_id=song_metadata["id"],
|
||||
)
|
||||
|
||||
if song_metadata["attributes"]["playParams"].get("isLibrary"):
|
||||
log.debug("library_song_no_m3u8_master_url")
|
||||
return None
|
||||
|
||||
if "extendedAssetUrls" not in song_metadata["attributes"]:
|
||||
song_metadata = (
|
||||
await self.base.apple_music_api.get_song(
|
||||
song_metadata["id"],
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
enhanced = song_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
|
||||
|
||||
if enhanced:
|
||||
log.debug("success", m3u8_master_url=enhanced)
|
||||
return enhanced
|
||||
|
||||
log.debug("no_m3u8_master_url")
|
||||
|
||||
return None
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
media_id: str,
|
||||
is_library: bool,
|
||||
m3u8_master_url: str | None = None,
|
||||
webplayback: dict | None = None,
|
||||
) -> StreamInfoAv:
|
||||
stream_info = None
|
||||
|
||||
if is_library and webplayback:
|
||||
stream_info = await self._get_library_stream_info(webplayback)
|
||||
elif webplayback or m3u8_master_url:
|
||||
for codec in self.codec_priority:
|
||||
if codec.is_web:
|
||||
stream_info = await self._get_web_stream_info(webplayback, codec)
|
||||
else:
|
||||
stream_info = await self._get_stream_info(m3u8_master_url, codec)
|
||||
|
||||
if stream_info:
|
||||
break
|
||||
|
||||
if not stream_info:
|
||||
raise GamdlInterfaceFormatNotAvailableError(
|
||||
media_id=media_id,
|
||||
formats=[codec.value for codec in self.codec_priority],
|
||||
)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def _get_stream_info(
|
||||
self,
|
||||
m3u8_master_url: str | None,
|
||||
codec: SongCodec,
|
||||
) -> StreamInfoAv | None:
|
||||
log = logger.bind(action="get_song_stream_info")
|
||||
|
||||
if not m3u8_master_url:
|
||||
log.debug("no_m3u8_master_url")
|
||||
return None
|
||||
|
||||
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:
|
||||
playlist = await self._get_playlist_from_user(m3u8_master_data)
|
||||
else:
|
||||
playlist = self._get_playlist_from_codec(
|
||||
m3u8_master_data,
|
||||
codec,
|
||||
)
|
||||
|
||||
if playlist is None:
|
||||
log.debug("no_matching_playlist", codec=codec.value)
|
||||
return None
|
||||
|
||||
stream_info = StreamInfo(use_single_content_key=False)
|
||||
stream_info.stream_url = (
|
||||
f"{m3u8_master_url.rpartition('/')[0]}/{playlist['uri']}"
|
||||
)
|
||||
stream_info.codec = playlist["stream_info"]["codecs"]
|
||||
is_mp4 = any(stream_info.codec.startswith(codec) for codec in MP4_FORMAT_CODECS)
|
||||
|
||||
session_key_metadata = self._get_audio_session_key_metadata(m3u8_master_data)
|
||||
|
||||
if session_key_metadata:
|
||||
asset_metadata = self._get_asset_metadata(m3u8_master_data)
|
||||
variant_id = playlist["stream_info"]["stable_variant_id"]
|
||||
drm_ids = asset_metadata[variant_id]["AUDIO-SESSION-KEY-IDS"]
|
||||
|
||||
stream_info.widevine_pssh = self._get_drm_uri_from_session_key(
|
||||
session_key_metadata,
|
||||
drm_ids,
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
|
||||
)
|
||||
stream_info.playready_pssh = self._get_drm_uri_from_session_key(
|
||||
session_key_metadata,
|
||||
drm_ids,
|
||||
"com.microsoft.playready",
|
||||
)
|
||||
stream_info.fairplay_key = self._get_drm_uri_from_session_key(
|
||||
session_key_metadata,
|
||||
drm_ids,
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
else:
|
||||
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,
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
|
||||
)
|
||||
stream_info.playready_pssh = self._get_drm_uri_from_m3u8_keys(
|
||||
m3u8_obj,
|
||||
"com.microsoft.playready",
|
||||
)
|
||||
stream_info.fairplay_key = self._get_drm_uri_from_m3u8_keys(
|
||||
m3u8_obj,
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
|
||||
stream_info_av = StreamInfoAv(
|
||||
audio_track=stream_info,
|
||||
file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A,
|
||||
)
|
||||
|
||||
log.debug("success", stream_info=stream_info_av)
|
||||
|
||||
return stream_info_av
|
||||
|
||||
def _get_m3u8_metadata(self, m3u8_data: dict, data_id: str) -> dict | None:
|
||||
for session_data in m3u8_data.get("session_data", []):
|
||||
if session_data["data_id"] == data_id:
|
||||
return json.loads(
|
||||
base64.b64decode(session_data["value"]).decode("utf-8")
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_audio_session_key_metadata(self, m3u8_data: dict) -> dict | None:
|
||||
return self._get_m3u8_metadata(
|
||||
m3u8_data,
|
||||
"com.apple.hls.AudioSessionKeyInfo",
|
||||
)
|
||||
|
||||
def _get_asset_metadata(self, m3u8_data: dict) -> dict | None:
|
||||
return self._get_m3u8_metadata(
|
||||
m3u8_data,
|
||||
"com.apple.hls.audioAssetMetadata",
|
||||
)
|
||||
|
||||
def _get_playlist_from_codec(
|
||||
self, m3u8_data: dict, codec: SongCodec
|
||||
) -> dict | None:
|
||||
matching_playlists = [
|
||||
playlist
|
||||
for playlist in m3u8_data["playlists"]
|
||||
if re.fullmatch(
|
||||
SONG_CODEC_REGEX_MAP[codec.value], playlist["stream_info"]["audio"]
|
||||
)
|
||||
]
|
||||
|
||||
if not matching_playlists:
|
||||
return None
|
||||
|
||||
return max(
|
||||
matching_playlists,
|
||||
key=lambda x: x["stream_info"]["average_bandwidth"],
|
||||
)
|
||||
|
||||
async def _get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
|
||||
if self.ask_codec_function:
|
||||
playlist = self.ask_codec_function(
|
||||
[playlist for playlist in m3u8_data["playlists"]]
|
||||
)
|
||||
if asyncio.iscoroutine(playlist):
|
||||
playlist = await playlist
|
||||
|
||||
return playlist
|
||||
|
||||
return None
|
||||
|
||||
def _get_drm_uri_from_session_key(
|
||||
self,
|
||||
drm_infos: dict,
|
||||
drm_ids: list,
|
||||
drm_key: str,
|
||||
) -> str | None:
|
||||
for drm_id in drm_ids:
|
||||
if drm_id != "1" and drm_key in drm_infos.get(drm_id, {}):
|
||||
return drm_infos[drm_id][drm_key]["URI"]
|
||||
return None
|
||||
|
||||
def _get_drm_uri_from_m3u8_keys(
|
||||
self,
|
||||
m3u8_obj: m3u8.M3U8,
|
||||
drm_key: str,
|
||||
) -> str | None:
|
||||
default_uri = DRM_DEFAULT_KEY_MAPPING[drm_key]
|
||||
|
||||
for key in m3u8_obj.keys:
|
||||
if key.keyformat == drm_key and key.uri != default_uri:
|
||||
return key.uri
|
||||
return None
|
||||
|
||||
async def _get_web_stream_info(
|
||||
self,
|
||||
webplayback: dict,
|
||||
codec: SongCodec,
|
||||
) -> StreamInfoAv:
|
||||
log = logger.bind(action="get_web_song_stream_info")
|
||||
|
||||
flavor = codec.flavor
|
||||
|
||||
stream_info = StreamInfo(
|
||||
use_cenc=codec.is_cenc,
|
||||
)
|
||||
asset = next(
|
||||
(i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor),
|
||||
None,
|
||||
)
|
||||
if not asset:
|
||||
log.debug("no_matching_asset", codec=codec.value, flavor=flavor)
|
||||
return None
|
||||
|
||||
stream_info.stream_url = asset["URL"]
|
||||
|
||||
m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(stream_info.stream_url)).text
|
||||
)
|
||||
|
||||
if stream_info.use_cenc:
|
||||
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
|
||||
else:
|
||||
stream_info.fairplay_key = m3u8_obj.keys[0].uri
|
||||
|
||||
stream_info_av = StreamInfoAv(
|
||||
media_id=webplayback["songList"][0]["songId"],
|
||||
audio_track=stream_info,
|
||||
file_format=MediaFileFormat.M4A,
|
||||
)
|
||||
log.debug("success", stream_info=stream_info_av)
|
||||
|
||||
return stream_info_av
|
||||
|
||||
async def _get_library_stream_info(
|
||||
self,
|
||||
webplayback: dict,
|
||||
) -> StreamInfoAv | None:
|
||||
log = logger.bind(action="get_library_song_stream_info")
|
||||
|
||||
stream_info = StreamInfo(drm_free=True)
|
||||
|
||||
if len(webplayback["songList"][0]["assets"]) == 0:
|
||||
log.debug("no_matching_asset")
|
||||
return None
|
||||
asset = webplayback["songList"][0]["assets"][0]
|
||||
|
||||
stream_info.stream_url = asset["URL"]
|
||||
|
||||
stream_info_av = StreamInfoAv(
|
||||
media_id=webplayback["songList"][0]["songId"],
|
||||
audio_track=stream_info,
|
||||
file_format=MediaFileFormat.M4A,
|
||||
)
|
||||
log.debug("success", stream_info=stream_info_av)
|
||||
|
||||
return stream_info_av
|
||||
|
||||
async def get_media(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
if not media.media_metadata:
|
||||
media.media_metadata = (
|
||||
await (
|
||||
self.base.apple_music_api.get_library_song(media.media_id)
|
||||
if media.is_library
|
||||
else self.base.apple_music_api.get_song(media.media_id)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
if media.media_metadata["attributes"]["playParams"].get("isLibrary"):
|
||||
catalog_metadata = self.base.get_catalog_metadata_from_library(
|
||||
media.media_metadata
|
||||
)
|
||||
if catalog_metadata:
|
||||
media.media_id = catalog_metadata["id"]
|
||||
media.is_library = False
|
||||
media.media_metadata = catalog_metadata
|
||||
|
||||
yield media
|
||||
|
||||
if not self.base.is_media_streamable(media.media_metadata):
|
||||
raise GamdlInterfaceMediaNotStreamableError(
|
||||
media_id=media.media_id,
|
||||
)
|
||||
|
||||
if media.playlist_metadata:
|
||||
media.playlist_tags = self.base.get_playlist_tags(
|
||||
media.playlist_metadata,
|
||||
media.index,
|
||||
)
|
||||
|
||||
media.cover = await self.base.get_cover(media.media_metadata)
|
||||
|
||||
media.lyrics = await self.get_lyrics(media.media_metadata)
|
||||
|
||||
if self.base.wrapper_api:
|
||||
playback = (
|
||||
await self.base.wrapper_api.get_playback(media.media_id)
|
||||
if not media.is_library
|
||||
else None
|
||||
)
|
||||
webplayback = (
|
||||
await self.base.apple_music_api.get_webplayback(
|
||||
media.media_id,
|
||||
media.is_library,
|
||||
)
|
||||
if media.is_library
|
||||
or any(codec.is_web for codec in self.codec_priority)
|
||||
else None
|
||||
)
|
||||
else:
|
||||
playback = None
|
||||
webplayback = await self.base.apple_music_api.get_webplayback(
|
||||
media.media_id,
|
||||
media.is_library,
|
||||
)
|
||||
|
||||
if playback:
|
||||
media.tags = self.base.get_tags_from_asset_info(
|
||||
playback["songList"][0]["assets"][0]["metadata"],
|
||||
media.lyrics.unsynced if media.lyrics else None,
|
||||
)
|
||||
else:
|
||||
media.tags = await self.base.get_tags_from_asset_info(
|
||||
webplayback["songList"][0]["assets"][0]["metadata"],
|
||||
media.lyrics.unsynced if media.lyrics else None,
|
||||
)
|
||||
|
||||
if not self.skip_stream_info:
|
||||
m3u8_master_url = await self.get_m3u8_master_url(
|
||||
playback,
|
||||
media.media_metadata,
|
||||
)
|
||||
|
||||
media.stream_info = await self.get_stream_info(
|
||||
media.media_id,
|
||||
media.is_library,
|
||||
m3u8_master_url,
|
||||
webplayback,
|
||||
)
|
||||
|
||||
if media.stream_info.audio_track.drm_free:
|
||||
pass
|
||||
elif (
|
||||
not self.base.wrapper_api
|
||||
and not media.stream_info.audio_track.widevine_pssh
|
||||
) or (
|
||||
self.base.wrapper_api
|
||||
and not media.stream_info.audio_track.fairplay_key
|
||||
and not media.stream_info.audio_track.use_cenc
|
||||
):
|
||||
raise GamdlInterfaceDecryptionNotAvailableError(media_id=media.media_id)
|
||||
elif media.stream_info.audio_track.widevine_pssh:
|
||||
media.decryption_key = DecryptionKeyAv(
|
||||
audio_track=await self.base.get_decryption_key(
|
||||
media.stream_info.audio_track.widevine_pssh,
|
||||
media.media_id,
|
||||
)
|
||||
)
|
||||
|
||||
media.partial = False
|
||||
|
||||
yield media
|
||||
@@ -1,5 +1,6 @@
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .enums import MediaFileFormat, MediaRating, MediaType
|
||||
|
||||
@@ -106,10 +107,10 @@ class MediaTags:
|
||||
|
||||
@dataclass
|
||||
class PlaylistTags:
|
||||
playlist_artist: str = None
|
||||
artist: str = None
|
||||
playlist_id: int = None
|
||||
playlist_title: str = None
|
||||
playlist_track: int = None
|
||||
title: str = None
|
||||
track: int = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -121,7 +122,9 @@ class StreamInfo:
|
||||
codec: str = None
|
||||
width: int = None
|
||||
height: int = None
|
||||
legacy: bool = None
|
||||
drm_free: bool = False
|
||||
use_cenc: bool = False
|
||||
use_single_content_key: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -142,3 +145,41 @@ 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
|
||||
is_library: bool = False
|
||||
index: int = 0
|
||||
total: int = 0
|
||||
partial: bool = True
|
||||
media_metadata: dict | None = None
|
||||
error: BaseException | None = None
|
||||
playlist_metadata: dict | None = None
|
||||
playlist_tags: PlaylistTags | None = None
|
||||
extra_tags: dict | None = None
|
||||
cover: Cover | None = None
|
||||
lyrics: Lyrics | None = None
|
||||
tags: MediaTags | None = None
|
||||
stream_info: StreamInfoAv | None = None
|
||||
decryption_key: DecryptionKeyAv | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppleMusicUrlInfo:
|
||||
storefront: str = None
|
||||
type: str = None
|
||||
slug: str = None
|
||||
id: str = None
|
||||
sub_id: str = None
|
||||
library_storefront: str = None
|
||||
library_type: str = None
|
||||
library_id: str = None
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import structlog
|
||||
|
||||
from .base import AppleMusicBaseInterface
|
||||
from .constants import UPLOADED_VIDEO_QUALITY_RANK
|
||||
from .enums import UploadedVideoQuality
|
||||
from .exceptions import (
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
)
|
||||
from .types import AppleMusicMedia, MediaFileFormat, MediaTags, StreamInfo, StreamInfoAv
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoInterface:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseInterface,
|
||||
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
|
||||
ask_quality_function: Callable[[dict], dict | None] | None = None,
|
||||
):
|
||||
self.base = base
|
||||
self.quality = quality
|
||||
self.ask_quality_function = ask_quality_function
|
||||
|
||||
def _get_best_stream_url(self, metadata: dict) -> str:
|
||||
best_quality = next(
|
||||
(
|
||||
quality
|
||||
for quality in UPLOADED_VIDEO_QUALITY_RANK
|
||||
if metadata["attributes"]["assetTokens"].get(quality)
|
||||
),
|
||||
None,
|
||||
)
|
||||
return metadata["attributes"]["assetTokens"][best_quality]
|
||||
|
||||
async def _get_stream_url_from_user(self, metadata: dict) -> str | None:
|
||||
if self.ask_quality_function:
|
||||
selected_quality = self.ask_quality_function(
|
||||
metadata["attributes"]["assetTokens"]
|
||||
)
|
||||
if asyncio.iscoroutine(selected_quality):
|
||||
selected_quality = await selected_quality
|
||||
return selected_quality
|
||||
|
||||
return None
|
||||
|
||||
async def _get_stream_url(
|
||||
self,
|
||||
metadata: dict,
|
||||
) -> str | None:
|
||||
if self.quality == UploadedVideoQuality.BEST:
|
||||
stream_url = self._get_best_stream_url(metadata)
|
||||
|
||||
if self.quality == UploadedVideoQuality.ASK:
|
||||
stream_url = await self._get_stream_url_from_user(metadata)
|
||||
|
||||
return stream_url
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
metadata: dict,
|
||||
) -> StreamInfo | None:
|
||||
log = logger.bind(
|
||||
action="get_uploaded_video_stream_info", media_id=metadata["id"]
|
||||
)
|
||||
|
||||
stream_url = await self._get_stream_url(metadata)
|
||||
if not stream_url:
|
||||
log.debug("no_stream_url_available")
|
||||
|
||||
return None
|
||||
|
||||
stream_info = StreamInfoAv(
|
||||
file_format=MediaFileFormat.M4V,
|
||||
video_track=StreamInfo(
|
||||
stream_url=stream_url,
|
||||
drm_free=True,
|
||||
),
|
||||
)
|
||||
|
||||
log.debug("success", stream_info=stream_info)
|
||||
|
||||
return stream_info
|
||||
|
||||
def get_tags(self, metadata: dict) -> MediaTags:
|
||||
log = logger.bind(action="get_uploaded_video_tags", media_id=metadata["id"])
|
||||
|
||||
attributes = metadata["attributes"]
|
||||
upload_date = attributes.get("uploadDate")
|
||||
|
||||
tags = MediaTags(
|
||||
artist=attributes.get("artistName"),
|
||||
date=self.base.parse_date(upload_date) if upload_date else None,
|
||||
title=attributes.get("name"),
|
||||
title_id=int(metadata["id"]),
|
||||
storefront=self.base.itunes_api.storefront_id,
|
||||
)
|
||||
|
||||
log.debug("success", tags=tags)
|
||||
|
||||
return tags
|
||||
|
||||
async def get_media(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
if not media.media_metadata:
|
||||
media.media_metadata = (
|
||||
await self.base.apple_music_api.get_uploaded_video(media.media_id)
|
||||
)["data"][0]
|
||||
|
||||
media.media_id = media["id"]
|
||||
|
||||
yield media
|
||||
|
||||
if not self.base.is_media_streamable(media.media_metadata):
|
||||
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
|
||||
|
||||
media.cover = await self.base.get_cover(media.media_metadata)
|
||||
|
||||
media.stream_info = await self.get_stream_info(media.media_metadata)
|
||||
if not media.stream_info:
|
||||
raise GamdlInterfaceFormatNotAvailableError(media.media_id)
|
||||
|
||||
media.tags = self.get_tags(media.media_metadata)
|
||||
|
||||
media.partial = False
|
||||
|
||||
yield media
|
||||
@@ -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=="""
|
||||
+14
-48
@@ -1,41 +1,13 @@
|
||||
import asyncio
|
||||
import json
|
||||
import string
|
||||
import subprocess
|
||||
import typing
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def raise_for_status(httpx_response: httpx.Response, valid_responses: set[int] = {200}):
|
||||
if httpx_response.status_code not in valid_responses:
|
||||
raise httpx._exceptions.HTTPError(
|
||||
f"HTTP error {httpx_response.status_code}: {httpx_response.text}"
|
||||
)
|
||||
|
||||
|
||||
def safe_json(httpx_response: httpx.Response) -> dict | None:
|
||||
try:
|
||||
return httpx_response.json()
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
async def get_response(
|
||||
url: str,
|
||||
valid_responses: set[int] = {200},
|
||||
) -> httpx.Response:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.get(url)
|
||||
raise_for_status(response, valid_responses)
|
||||
return response
|
||||
|
||||
|
||||
async def async_subprocess(*args: str, silent: bool = False) -> None:
|
||||
if silent:
|
||||
additional_args = {
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
"stdout": asyncio.subprocess.PIPE,
|
||||
"stderr": asyncio.subprocess.PIPE,
|
||||
}
|
||||
else:
|
||||
additional_args = {}
|
||||
@@ -44,10 +16,20 @@ async def async_subprocess(*args: str, silent: bool = False) -> None:
|
||||
*args,
|
||||
**additional_args,
|
||||
)
|
||||
await proc.communicate()
|
||||
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode != 0:
|
||||
raise Exception(f'"{args[0]}" exited with code {proc.returncode}')
|
||||
msg = (
|
||||
f"Exited with code {proc.returncode}: {' '.join(str(arg) for arg in args)}"
|
||||
)
|
||||
|
||||
if stdout:
|
||||
msg += f"\nstdout:\n{stdout.decode()}"
|
||||
if stderr:
|
||||
msg += f"\nstderr:\n{stderr.decode()}"
|
||||
|
||||
raise Exception(msg)
|
||||
|
||||
|
||||
async def safe_gather(
|
||||
@@ -66,22 +48,6 @@ async def safe_gather(
|
||||
)
|
||||
|
||||
|
||||
async def sequential_gather(
|
||||
*tasks: typing.Awaitable[typing.Any],
|
||||
interval: float = 0.5,
|
||||
) -> list[typing.Any]:
|
||||
results = []
|
||||
for i, task in enumerate(tasks):
|
||||
try:
|
||||
result = await task
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
results.append(e)
|
||||
if interval > 0 and i < len(tasks) - 1:
|
||||
await asyncio.sleep(interval)
|
||||
return results
|
||||
|
||||
|
||||
class CustomStringFormatter(string.Formatter):
|
||||
def format_field(self, value: typing.Any, format_spec: str) -> str:
|
||||
if isinstance(value, tuple) and len(value) == 2:
|
||||
|
||||
+9
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
version = "2.9.2"
|
||||
version = "3.7"
|
||||
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -11,11 +11,13 @@ dependencies = [
|
||||
"colorama>=0.4.6",
|
||||
"dataclass-click>=1.0.4",
|
||||
"httpx>=0.28.1",
|
||||
"httpx-retries>=0.4.6",
|
||||
"inquirerpy>=0.3.4",
|
||||
"m3u8>=6.0.0",
|
||||
"mutagen>=1.47.0",
|
||||
"pillow>=12.0.0",
|
||||
"pywidevine>=1.8.0",
|
||||
"structlog>=25.5.0",
|
||||
"yt-dlp>=2025.10.22",
|
||||
]
|
||||
|
||||
@@ -24,3 +26,9 @@ Repository = "https://github.com/glomatico/gamdl"
|
||||
|
||||
[project.scripts]
|
||||
gamdl = "gamdl.cli.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
]
|
||||
|
||||
@@ -29,6 +29,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-asyncio-runner"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-datetime-fromisoformat"
|
||||
version = "2.0.3"
|
||||
@@ -214,7 +223,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gamdl"
|
||||
version = "2.9.2"
|
||||
version = "3.7"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "async-lru" },
|
||||
@@ -222,14 +231,22 @@ dependencies = [
|
||||
{ name = "colorama" },
|
||||
{ name = "dataclass-click" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-retries" },
|
||||
{ name = "inquirerpy" },
|
||||
{ name = "m3u8" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pywidevine" },
|
||||
{ name = "structlog" },
|
||||
{ name = "yt-dlp" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "async-lru", specifier = ">=2.0.5" },
|
||||
@@ -237,14 +254,22 @@ requires-dist = [
|
||||
{ name = "colorama", specifier = ">=0.4.6" },
|
||||
{ name = "dataclass-click", specifier = ">=1.0.4" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "httpx-retries", specifier = ">=0.4.6" },
|
||||
{ name = "inquirerpy", specifier = ">=0.3.4" },
|
||||
{ name = "m3u8", specifier = ">=6.0.0" },
|
||||
{ name = "mutagen", specifier = ">=1.47.0" },
|
||||
{ name = "pillow", specifier = ">=12.0.0" },
|
||||
{ name = "pywidevine", specifier = ">=1.8.0" },
|
||||
{ name = "structlog", specifier = ">=25.5.0" },
|
||||
{ name = "yt-dlp", specifier = ">=2025.10.22" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -282,6 +307,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-retries"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/13/5eac2df576c02280f79e4639a6d4c93a25cfe94458275f5aa55f5e6c8ea0/httpx_retries-0.4.6.tar.gz", hash = "sha256:a076d8a5ede5d5794e9c241da17b15b393b482129ddd2fdf1fa56a3fa1f28a7f", size = 13466, upload-time = "2026-02-17T16:16:05.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/97/63f56da4400034adde22adfe7524635dba068f17d6858f92ecd96f55b53e/httpx_retries-0.4.6-py3-none-any.whl", hash = "sha256:d66d912173b844e065ffb109345a453b922f4c2cd9c9e11139304cb33e7a1ee1", size = 8490, upload-time = "2026-02-17T16:16:04.137Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@@ -291,6 +328,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inquirerpy"
|
||||
version = "0.3.4"
|
||||
@@ -325,6 +371,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pfzy"
|
||||
version = "0.3.4"
|
||||
@@ -432,6 +487,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.52"
|
||||
@@ -493,6 +557,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pymp4"
|
||||
version = "1.4.0"
|
||||
@@ -505,6 +578,38 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a2/27fea39af627c0ce5dbf6108bf969ea8f5fc9376d29f11282a80e3426f1d/pymp4-1.4.0-py3-none-any.whl", hash = "sha256:3401666c1e2a97ac94dffb18c5a5dcbd46d0a436da5272d378a6f9f6506dd12d", size = 14832, upload-time = "2023-05-07T15:01:32.293Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywidevine"
|
||||
version = "1.8.0"
|
||||
@@ -611,6 +716,72 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "structlog"
|
||||
version = "25.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
||||
Reference in New Issue
Block a user