mirror of
https://github.com/glomatico/gamdl.git
synced 2026-06-13 12:15:18 +03:00
Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 29f68f6bc4 | |||
| e77c6b24b4 | |||
| ba315dcb95 | |||
| 4187fad734 | |||
| f36edf4bbd | |||
| 50478d427e | |||
| 45461007a9 | |||
| 79a03d4f4c | |||
| beb508529a | |||
| 87cf8c7789 | |||
| 9e3f740eec | |||
| 7281f5c949 | |||
| d32781b23f | |||
| 5f2c74399e | |||
| 6b67c435fa | |||
| 240ba7d4de | |||
| 02c19963b4 | |||
| 2e2fef1426 | |||
| ae3b2e1c6d | |||
| 6516855be9 | |||
| 77cbb8a7ca | |||
| 18bc6595a9 | |||
| da2c3d5f1e | |||
| abe364aad1 | |||
| 10b529d6fd | |||
| afe42848d0 | |||
| b3b5e6d1b2 | |||
| d11e937c6a |
@@ -26,31 +26,44 @@ A command-line app for downloading Apple Music songs, music videos and post vide
|
||||
- **Apple Music Cookies** - Export your browser cookies in Netscape format while logged in with an active subscription at the Apple Music website:
|
||||
- **Firefox**: [Export Cookies](https://addons.mozilla.org/addon/export-cookies-txt)
|
||||
- **Chromium**: [Get cookies.txt LOCALLY](https://chromewebstore.google.com/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
||||
- **FFmpeg** - Must be in your system PATH
|
||||
- **Windows**: [AnimMouse's FFmpeg Builds](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases)
|
||||
- **Linux**: [John Van Sickle's FFmpeg Builds](https://johnvansickle.com/ffmpeg/)
|
||||
|
||||
### Optional
|
||||
### Dependencies
|
||||
|
||||
Add these tools to your system PATH for additional features:
|
||||
Add these tools to your system PATH or specify their paths via command-line arguments or the config file. The tools needed depend on which audio quality, video format, and download mode you want. Use the table below to find the required tools for your use case:
|
||||
|
||||
- **[mp4decrypt](https://www.bento4.com/downloads/)** - Required for `mp4box` remux mode, music videos, and experimental codecs
|
||||
- **[MP4Box](https://gpac.io/downloads/gpac-nightly-builds/)** - Required for `mp4box` remux mode
|
||||
- **[N_m3u8DL-RE](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest)** - Required for `nm3u8dlre` download mode, which is faster than the default downloader
|
||||
- **[Wrapper](#️-wrapper)** - For downloading songs in ALAC and other experimental codecs without API limitations
|
||||
| Use Case | Configuration | Required Tools |
|
||||
|---|---|---|
|
||||
| **Songs in Legacy Codecs** | `song_codec_priority: aac-legacy\|aac-he-legacy` | None |
|
||||
| **Songs in Non Legacy Codecs** | `song_codec_priority: aac\|aac-he\|aac-binaural\|aac-downmix\|aac-he-binaural\|aac-he-downmix\|atmos\|ac3`<br/>`use_wrapper: true` | Wrapper |
|
||||
| **Music Videos** | `music_video_remux_mode: ffmpeg` | FFmpeg<br/>mp4decrypt |
|
||||
| | `music_video_remux_mode: mp4box` | MP4Box<br/>mp4decrypt |
|
||||
| **Faster Downloads** | `download_mode: nm3u8dlre` | N_m3u8DL-RE |
|
||||
|
||||
#### Tool Reference
|
||||
|
||||
| Tool | Download | Purpose |
|
||||
|---|---|---|
|
||||
| **FFmpeg** | [Windows](https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases) / [Linux](https://johnvansickle.com/ffmpeg/) | Required for music video remuxing with FFmpeg mode |
|
||||
| **MP4Box** | [Download](https://gpac.io/downloads/gpac-nightly-builds/) | Alternative for music video remuxing |
|
||||
| **mp4decrypt** | [Download](https://www.bento4.com/downloads/) | Decrypts MP4 files when used with MP4Box |
|
||||
| **N_m3u8DL-RE** | [Download](https://github.com/nilaoda/N_m3u8DL-RE/releases/latest) | Faster download alternative |
|
||||
| **Wrapper** | [Download](https://github.com/WorldObservationLog/wrapper) | For downloading songs in ALAC and other experimental codecs |
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
**Install Gamdl via pip:**
|
||||
1. **Install Gamdl via pip:**
|
||||
|
||||
```bash
|
||||
pip install gamdl
|
||||
```
|
||||
```bash
|
||||
pip install gamdl
|
||||
```
|
||||
|
||||
**Setup cookies:**
|
||||
2. **Set up the cookies file:**
|
||||
- Place the cookies file in the working directory as `cookies.txt`, or
|
||||
- Specify the path using `--cookies-path` or in the config file
|
||||
|
||||
1. Place your cookies file in the working directory as `cookies.txt`, or
|
||||
2. Specify the path using `--cookies-path` or in the config file
|
||||
3. **Optional: Set up tools** (only if you need the functionality)
|
||||
|
||||
See the [Dependencies](#dependencies) section to determine which tools you need based on your use case, then follow the [Tool Reference](#tool-reference) for download and installation instructions.
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
@@ -66,6 +79,7 @@ gamdl [OPTIONS] URLS...
|
||||
- Music Videos
|
||||
- Artists
|
||||
- Post Videos
|
||||
- Apple Music Classical
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -109,61 +123,66 @@ The file is created automatically on first run. Command-line arguments override
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| ------------------------------- | ------------------------------- | ---------------------------------------------- |
|
||||
| **General Options** | | |
|
||||
| `--read-urls-as-txt`, `-r` | Read URLs from text files | `false` |
|
||||
| `--config-path` | Config file path | `<home>/.gamdl/config.ini` |
|
||||
| `--log-level` | Logging level | `INFO` |
|
||||
| `--log-file` | Log file path | - |
|
||||
| `--no-exceptions` | Don't print exceptions | `false` |
|
||||
| `--no-config-file`, `-n` | Don't use a config file | `false` |
|
||||
| **Apple Music Options** | | |
|
||||
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
|
||||
| `--wrapper-account-url` | Wrapper account URL | `http://127.0.0.1:30020` |
|
||||
| `--language`, `-l` | Metadata language | `en-US` |
|
||||
| **Output Options** | | |
|
||||
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
|
||||
| `--temp-path` | Temporary directory path | `.` |
|
||||
| `--wvd-path` | .wvd file path | - |
|
||||
| `--overwrite` | Overwrite existing files | `false` |
|
||||
| `--save-cover`, `-s` | Save cover as separate file | `false` |
|
||||
| `--save-playlist` | Save M3U8 playlist file | `false` |
|
||||
| **Download Options** | | |
|
||||
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
|
||||
| `--mp4decrypt-path` | mp4decrypt executable path | `mp4decrypt` |
|
||||
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
|
||||
| `--mp4box-path` | MP4Box executable path | `MP4Box` |
|
||||
| `--use-wrapper` | Use wrapper | `false` |
|
||||
| `--wrapper-decrypt-ip` | Wrapper decryption server IP | `127.0.0.1:10020` |
|
||||
| `--download-mode` | Download mode | `ytdlp` |
|
||||
| `--remux-mode` | Remux mode | `ffmpeg` |
|
||||
| `--cover-format` | Cover format | `jpg` |
|
||||
| **Template Options** | | |
|
||||
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
|
||||
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
|
||||
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
|
||||
| `--single-disc-file-template` | Single disc file template | `{track:02d} {title}` |
|
||||
| `--multi-disc-file-template` | Multi disc file template | `{disc}-{track:02d} {title}` |
|
||||
| `--no-album-file-template` | No album file template | `{title}` |
|
||||
| `--playlist-file-template` | Playlist file template | `Playlists/{playlist_artist}/{playlist_title}` |
|
||||
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` | Comma-separated tags to exclude | - |
|
||||
| `--cover-size` | Cover size in pixels | `1200` |
|
||||
| `--truncate` | Max filename length | - |
|
||||
| **Song Options** | | |
|
||||
| `--song-codec` | Song codec | `aac-legacy` |
|
||||
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
|
||||
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
|
||||
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
|
||||
| `--use-album-date` | Use album release date for songs | `false` |
|
||||
| `--fetch-extra-tags` | Fetch extra tags from preview (normalization and smooth playback) | `false` |
|
||||
| **Music Video Options** | | |
|
||||
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
|
||||
| `--music-video-remux-format` | Music video remux format | `m4v` |
|
||||
| `--music-video-resolution` | Max music video resolution | `1080p` |
|
||||
| **Post Video Options** | | |
|
||||
| `--uploaded-video-quality` | Post video quality | `best` |
|
||||
| Option | Description | Default |
|
||||
| ------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| **General Options** | | |
|
||||
| `--read-urls-as-txt`, `-r` | Read URLs from text files | `false` |
|
||||
| `--config-path` | Config file path | `<home>/.gamdl/config.ini` |
|
||||
| `--log-level` | Logging level | `INFO` |
|
||||
| `--log-file` | Log file path | - |
|
||||
| `--no-exceptions` | Don't print exceptions | `false` |
|
||||
| `--artist-auto-select` | Automatically select artist content to download (artist URLs) | - |
|
||||
| `--database-path` | Path to the SQLite database file for registering downloaded media | - |
|
||||
| `--no-config-file`, `-n` | Don't use a config file | `false` |
|
||||
| **Apple Music Options** | | |
|
||||
| `--cookies-path`, `-c` | Cookies file path | `./cookies.txt` |
|
||||
| `--wrapper-account-url` | Wrapper account URL | `http://127.0.0.1:30020` |
|
||||
| `--language`, `-l` | Metadata language | `en-US` |
|
||||
| **Output Options** | | |
|
||||
| `--cover-format` | Cover format | `jpg` |
|
||||
| `--cover-size` | Cover size in pixels | `1200` |
|
||||
| `--wvd-path` | .wvd file path | - |
|
||||
| `--wrapper-m3u8-ip` | Wrapper m3u8 IP address and port | - |
|
||||
| **Song Options** | | |
|
||||
| `--synced-lyrics-format` | Synced lyrics format | `lrc` |
|
||||
| `--song-codec-priority` | Comma-separated codec priority | `aac-legacy` |
|
||||
| `--use-album-date` | Use album release date for songs | `false` |
|
||||
| `--no-synced-lyrics` | Don't download synced lyrics | `false` |
|
||||
| `--synced-lyrics-only` | Download only synced lyrics | `false` |
|
||||
| **Music Video Options** | | |
|
||||
| `--music-video-resolution` | Max music video resolution | `1080p` |
|
||||
| `--music-video-codec-priority` | Comma-separated codec priority | `h264,h265` |
|
||||
| `--music-video-remux-mode` | Remux mode | `ffmpeg` |
|
||||
| `--music-video-remux-format` | Music video remux format | `m4v` |
|
||||
| **Post Video Options** | | |
|
||||
| `--uploaded-video-quality` | Post video quality | `best` |
|
||||
| **Download & Path Options** | | |
|
||||
| `--output-path`, `-o` | Output directory path | `./Apple Music` |
|
||||
| `--temp-path` | Temporary directory path | `.` |
|
||||
| `--nm3u8dlre-path` | N_m3u8DL-RE executable path | `N_m3u8DL-RE` |
|
||||
| `--mp4decrypt-path` | mp4decrypt executable path | `mp4decrypt` |
|
||||
| `--ffmpeg-path` | FFmpeg executable path | `ffmpeg` |
|
||||
| `--mp4box-path` | MP4Box executable path | `MP4Box` |
|
||||
| `--use-wrapper` | Use wrapper for decrypting songs | `false` |
|
||||
| `--wrapper-decrypt-ip` | Wrapper decryption server IP | `127.0.0.1:10020` |
|
||||
| `--download-mode` | Download mode | `ytdlp` |
|
||||
| **Template Options** | | |
|
||||
| `--album-folder-template` | Album folder template | `{album_artist}/{album}` |
|
||||
| `--compilation-folder-template` | Compilation folder template | `Compilations/{album}` |
|
||||
| `--no-album-folder-template` | No album folder template | `{artist}/Unknown Album` |
|
||||
| `--playlist-folder-template` | Playlist folder template | `Playlists/{playlist_artist}/{playlist_title}` |
|
||||
| `--single-disc-file-template` | Single disc file template | `{track:02d} {title}` |
|
||||
| `--multi-disc-file-template` | Multi disc file template | `{disc}-{track:02d} {title}` |
|
||||
| `--no-album-file-template` | No album file template | `{title}` |
|
||||
| `--playlist-file-template` | Playlist file template | `Playlists/{playlist_artist}/{playlist_title}` |
|
||||
| `--date-tag-template` | Date tag template | `%Y-%m-%dT%H:%M:%SZ` |
|
||||
| `--exclude-tags` | Comma-separated tags to exclude | - |
|
||||
| `--truncate` | Max filename length | - |
|
||||
| **File Output Options** | | |
|
||||
| `--overwrite` | Overwrite existing files | `false` |
|
||||
| `--save-cover`, `-s` | Save cover as separate file | `false` |
|
||||
| `--save-playlist` | Save M3U8 playlist file | `false` |
|
||||
|
||||
|
||||
### Template Variables
|
||||
|
||||
@@ -193,6 +212,9 @@ The file is created automatically on first run. Command-line arguments override
|
||||
|
||||
- `ytdlp`, `nm3u8dlre`
|
||||
|
||||
> [!NOTE]
|
||||
> - **yt-dlp is only used as a file download library**. Media is still fetched directly from Apple Music's servers, and yt-dlp is only responsible for handling the file download process.
|
||||
|
||||
### Remux Mode
|
||||
|
||||
- `ffmpeg`
|
||||
@@ -254,9 +276,19 @@ Use ISO 639-1 language codes (e.g., `en-US`, `es-ES`, `ja-JP`, `pt-BR`). Don't a
|
||||
- `best` - Up to 1080p with AAC 256kbps
|
||||
- `ask` - Interactive quality selection
|
||||
|
||||
### Artist Auto-Select Options
|
||||
|
||||
- `main-albums`
|
||||
- `compilation-albums`
|
||||
- `live-albums`
|
||||
- `singles-eps`
|
||||
- `all-albums`
|
||||
- `top-songs`
|
||||
- `music-videos`
|
||||
|
||||
## ⚙️ Wrapper
|
||||
|
||||
Use the [wrapper](https://github.com/WorldObservationLog/wrapper) to download songs in ALAC and other experimental codecs without API limitations. Cookies, FFmpeg, MP4Box, or mp4decrypt are not required when using the 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
|
||||
|
||||
@@ -271,7 +303,7 @@ Use Gamdl as a library in your Python projects:
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
from gamdl.api import AppleMusicApi, ItunesApi
|
||||
from gamdl.api import AppleMusicApi
|
||||
from gamdl.downloader import (
|
||||
AppleMusicBaseDownloader,
|
||||
AppleMusicDownloader,
|
||||
@@ -280,63 +312,79 @@ from gamdl.downloader import (
|
||||
AppleMusicUploadedVideoDownloader,
|
||||
)
|
||||
from gamdl.interface import (
|
||||
AppleMusicBaseInterface,
|
||||
AppleMusicInterface,
|
||||
AppleMusicMusicVideoInterface,
|
||||
AppleMusicSongInterface,
|
||||
AppleMusicUploadedVideoInterface,
|
||||
)
|
||||
|
||||
|
||||
async def main():
|
||||
# Create AppleMusicApi instance (from cookies or wrapper)
|
||||
# Create AppleMusicApi instance from cookies
|
||||
apple_music_api = await AppleMusicApi.create_from_netscape_cookies(
|
||||
cookies_path="cookies.txt",
|
||||
)
|
||||
itunes_api = ItunesApi(
|
||||
apple_music_api.storefront,
|
||||
apple_music_api.language,
|
||||
)
|
||||
|
||||
# Check subscription
|
||||
assert apple_music_api.active_subscription
|
||||
if not apple_music_api.active_subscription:
|
||||
print("No active Apple Music subscription")
|
||||
return
|
||||
|
||||
# Set up interfaces
|
||||
interface = AppleMusicInterface(apple_music_api, itunes_api)
|
||||
song_interface = AppleMusicSongInterface(interface)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(interface)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
|
||||
|
||||
# Set up base downloader and specialized downloaders
|
||||
base_downloader = AppleMusicBaseDownloader()
|
||||
song_downloader = AppleMusicSongDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=song_interface,
|
||||
)
|
||||
music_video_downloader = AppleMusicMusicVideoDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=music_video_interface,
|
||||
)
|
||||
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=uploaded_video_interface,
|
||||
# Create base interface
|
||||
base_interface = await AppleMusicBaseInterface.create(
|
||||
apple_music_api=apple_music_api,
|
||||
)
|
||||
|
||||
# Main downloader
|
||||
downloader = AppleMusicDownloader(
|
||||
# Create specialized interfaces
|
||||
song_interface = AppleMusicSongInterface(
|
||||
base=base_interface,
|
||||
)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(
|
||||
base=base_interface,
|
||||
)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(
|
||||
base=base_interface,
|
||||
)
|
||||
|
||||
# Create main interface
|
||||
interface = AppleMusicInterface(
|
||||
song=song_interface,
|
||||
music_video=music_video_interface,
|
||||
uploaded_video=uploaded_video_interface,
|
||||
)
|
||||
|
||||
# Create base downloader
|
||||
base_downloader = AppleMusicBaseDownloader(
|
||||
interface=interface,
|
||||
base_downloader=base_downloader,
|
||||
song_downloader=song_downloader,
|
||||
music_video_downloader=music_video_downloader,
|
||||
uploaded_video_downloader=uploaded_video_downloader,
|
||||
)
|
||||
|
||||
# Download a song
|
||||
# Create specialized downloaders
|
||||
song_downloader = AppleMusicSongDownloader(base=base_downloader)
|
||||
music_video_downloader = AppleMusicMusicVideoDownloader(
|
||||
base=base_downloader,
|
||||
)
|
||||
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(base=base_downloader)
|
||||
|
||||
# Create main downloader
|
||||
downloader = AppleMusicDownloader(
|
||||
song=song_downloader,
|
||||
music_video=music_video_downloader,
|
||||
uploaded_video=uploaded_video_downloader,
|
||||
)
|
||||
|
||||
# Download from URL
|
||||
url = "https://music.apple.com/us/album/never-gonna-give-you-up-2022-remaster/1624945511?i=1624945512"
|
||||
url_info = downloader.get_url_info(url)
|
||||
if url_info:
|
||||
download_queue = await downloader.get_download_queue(url_info)
|
||||
if download_queue:
|
||||
for download_item in download_queue:
|
||||
await downloader.download(download_item)
|
||||
|
||||
download_queue = []
|
||||
async for media in downloader.get_download_item_from_url(url):
|
||||
download_queue.append(media)
|
||||
|
||||
for download_item in download_queue:
|
||||
try:
|
||||
await downloader.download(download_item)
|
||||
except Exception as e:
|
||||
print(f"Error downloading: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
__version__ = "2.8.7"
|
||||
__version__ = "3.2"
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from .apple_music_api import AppleMusicApi
|
||||
from .itunes_api import ItunesApi
|
||||
from .apple_music import AppleMusicApi
|
||||
from .exceptions import *
|
||||
from .itunes import ItunesApi
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
import re
|
||||
from http.cookiejar import MozillaCookieJar
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from httpx_retries import Retry, RetryTransport
|
||||
|
||||
from .constants import (
|
||||
APPLE_MUSIC_ACCOUNT_INFO_API_URI,
|
||||
APPLE_MUSIC_ALBUM_API_URI,
|
||||
APPLE_MUSIC_AMP_API_URL,
|
||||
APPLE_MUSIC_ARTIST_API_URI,
|
||||
APPLE_MUSIC_COOKIE_DOMAIN,
|
||||
APPLE_MUSIC_HOMEPAGE_URL,
|
||||
APPLE_MUSIC_LIBRARY_ALBUM_API_URI,
|
||||
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI,
|
||||
APPLE_MUSIC_LICENSE_API_URL,
|
||||
APPLE_MUSIC_MUSIC_VIDEO_API_URI,
|
||||
APPLE_MUSIC_PLAYLIST_API_URI,
|
||||
APPLE_MUSIC_SEARCH_API_URI,
|
||||
APPLE_MUSIC_SONG_API_URI,
|
||||
APPLE_MUSIC_UPLOADED_VIDEO_API_URL,
|
||||
APPLE_MUSIC_WEBPLAYBACK_API_URL,
|
||||
)
|
||||
from .exceptions import GamdlApiResponseError
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicApi:
|
||||
def __init__(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
token: str,
|
||||
storefront: str,
|
||||
language: str,
|
||||
media_user_token: str | None = None,
|
||||
account_info: dict | None = None,
|
||||
) -> None:
|
||||
self.token = token
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
self.media_user_token = media_user_token
|
||||
self.account_info = account_info
|
||||
self.client = client
|
||||
|
||||
@property
|
||||
def active_subscription(self) -> bool:
|
||||
if not self.account_info:
|
||||
return False
|
||||
|
||||
return (
|
||||
self.account_info.get("meta", {})
|
||||
.get("subscription", {})
|
||||
.get("active", False)
|
||||
)
|
||||
|
||||
@property
|
||||
def account_restrictions(self) -> dict | None:
|
||||
if not self.account_info:
|
||||
return None
|
||||
|
||||
data = self.account_info.get("data", [])
|
||||
if not data:
|
||||
return None
|
||||
return data[0].get("attributes", {}).get("restrictions")
|
||||
|
||||
@staticmethod
|
||||
async def get_token() -> str:
|
||||
log = logger.bind(action="get_token")
|
||||
|
||||
response = None
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
APPLE_MUSIC_HOMEPAGE_URL,
|
||||
follow_redirects=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
home_page = response.text
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching Apple Music homepage",
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
index_js_uri_match = re.search(
|
||||
r"/(assets/index-legacy[~-][^/\"]+\.js)",
|
||||
home_page,
|
||||
)
|
||||
if not index_js_uri_match:
|
||||
raise GamdlApiResponseError(
|
||||
"Error finding index.js URI in Apple Music homepage"
|
||||
)
|
||||
index_js_uri = index_js_uri_match.group(1)
|
||||
|
||||
response = None
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{APPLE_MUSIC_HOMEPAGE_URL}/{index_js_uri}"
|
||||
)
|
||||
response.raise_for_status()
|
||||
index_js_page = response.text
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching index.js page",
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
token_match = re.search('(?=eyJh)(.*?)(?=")', index_js_page)
|
||||
if not token_match:
|
||||
raise GamdlApiResponseError("Error finding token in index.js page")
|
||||
token = token_match.group(1)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
async def get_account_info(
|
||||
token: str,
|
||||
media_user_token: str,
|
||||
meta: str = "subscription",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_account_info", meta=meta)
|
||||
|
||||
response = None
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
APPLE_MUSIC_AMP_API_URL + APPLE_MUSIC_ACCOUNT_INFO_API_URI,
|
||||
params={
|
||||
"meta": meta,
|
||||
},
|
||||
headers={
|
||||
"authorization": f"Bearer {token}",
|
||||
"origin": APPLE_MUSIC_HOMEPAGE_URL,
|
||||
"cookie": f"media-user-token={media_user_token}",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
account_info = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching account info",
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
log.debug("success", account_info=account_info)
|
||||
|
||||
return account_info
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
storefront: str | None = "us",
|
||||
language: str = "en-US",
|
||||
token: str | None = None,
|
||||
media_user_token: str | None = None,
|
||||
) -> "AppleMusicApi":
|
||||
token = token or await cls.get_token()
|
||||
account_info = (
|
||||
await cls.get_account_info(token, media_user_token)
|
||||
if media_user_token
|
||||
else None
|
||||
)
|
||||
storefront = (
|
||||
account_info["meta"]["subscription"]["storefront"]
|
||||
if account_info
|
||||
else storefront
|
||||
)
|
||||
if not storefront:
|
||||
raise ValueError(
|
||||
"Storefront must be provided if it cannot be determined from account info"
|
||||
)
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
headers={
|
||||
"authorization": f"Bearer {token}",
|
||||
"origin": APPLE_MUSIC_HOMEPAGE_URL,
|
||||
},
|
||||
transport=RetryTransport(
|
||||
retry=Retry(
|
||||
total=6,
|
||||
backoff_factor=1,
|
||||
status_forcelist=[429, 500, 502, 503, 504],
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
if media_user_token:
|
||||
client.headers.update(
|
||||
{
|
||||
"cookie": f"media-user-token={media_user_token}",
|
||||
}
|
||||
)
|
||||
|
||||
api = cls(
|
||||
client=client,
|
||||
token=token,
|
||||
storefront=storefront,
|
||||
language=language,
|
||||
media_user_token=media_user_token,
|
||||
account_info=account_info,
|
||||
)
|
||||
return api
|
||||
|
||||
@classmethod
|
||||
async def create_from_netscape_cookies(
|
||||
cls,
|
||||
cookies_path: str = "./cookies.txt",
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "AppleMusicApi":
|
||||
cookies = MozillaCookieJar(cookies_path)
|
||||
cookies.load(ignore_discard=True, ignore_expires=True)
|
||||
parse_cookie = lambda name: next(
|
||||
(
|
||||
cookie.value
|
||||
for cookie in cookies
|
||||
if cookie.name == name and cookie.domain == APPLE_MUSIC_COOKIE_DOMAIN
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
media_user_token = parse_cookie("media-user-token")
|
||||
if not media_user_token:
|
||||
raise ValueError(
|
||||
'"media-user-token" cookie not found in cookies. '
|
||||
"Make sure you have exported the cookies from the Apple Music webpage "
|
||||
"and are logged in with an active subscription."
|
||||
)
|
||||
|
||||
return await cls.create(
|
||||
media_user_token=media_user_token,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def create_from_wrapper(
|
||||
cls,
|
||||
wrapper_account_url: str = "http://127.0.0.1:30020/",
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> "AppleMusicApi":
|
||||
response = None
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(wrapper_account_url)
|
||||
response.raise_for_status()
|
||||
wrapper_account_info = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching wrapper account info",
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
return await cls.create(
|
||||
media_user_token=wrapper_account_info["music_token"],
|
||||
token=wrapper_account_info["dev_token"],
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _amp_request(
|
||||
self,
|
||||
uri: str,
|
||||
params: dict | None = None,
|
||||
) -> dict:
|
||||
response = None
|
||||
try:
|
||||
response = await self.client.get(
|
||||
APPLE_MUSIC_AMP_API_URL + uri,
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching from AMP API",
|
||||
content=response.text if response is not None else None,
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
if "errors" in response_json:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching from AMP API",
|
||||
content=response_json["errors"],
|
||||
)
|
||||
|
||||
return response_json
|
||||
|
||||
async def get_song(
|
||||
self,
|
||||
song_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
include: str = "lyrics,albums",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_song", song_id=song_id)
|
||||
|
||||
song = await self._amp_request(
|
||||
APPLE_MUSIC_SONG_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
song_id=song_id,
|
||||
),
|
||||
{
|
||||
"extend": extend,
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", song=song)
|
||||
|
||||
return song
|
||||
|
||||
async def get_music_video(
|
||||
self,
|
||||
music_video_id: str,
|
||||
include: str = "albums",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_music_video", music_video_id=music_video_id)
|
||||
|
||||
music_video = await self._amp_request(
|
||||
APPLE_MUSIC_MUSIC_VIDEO_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
music_video_id=music_video_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", music_video=music_video)
|
||||
|
||||
return music_video
|
||||
|
||||
async def get_uploaded_video(
|
||||
self,
|
||||
uploaded_video_id: str,
|
||||
) -> dict:
|
||||
log = logger.bind(
|
||||
action="get_uploaded_video", uploaded_video_id=uploaded_video_id
|
||||
)
|
||||
|
||||
uploaded_video = await self._amp_request(
|
||||
APPLE_MUSIC_UPLOADED_VIDEO_API_URL.format(
|
||||
storefront=self.storefront,
|
||||
uploaded_video_id=uploaded_video_id,
|
||||
)
|
||||
)
|
||||
|
||||
log.debug("success", uploaded_video=uploaded_video)
|
||||
|
||||
return uploaded_video
|
||||
|
||||
async def get_album(
|
||||
self,
|
||||
album_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_album", album_id=album_id)
|
||||
|
||||
album = await self._amp_request(
|
||||
APPLE_MUSIC_ALBUM_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
album_id=album_id,
|
||||
),
|
||||
{
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", album=album)
|
||||
|
||||
return album
|
||||
|
||||
async def get_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
limit_tracks: int = 300,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_playlist", playlist_id=playlist_id)
|
||||
|
||||
playlist = await self._amp_request(
|
||||
APPLE_MUSIC_PLAYLIST_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
playlist_id=playlist_id,
|
||||
),
|
||||
{
|
||||
"limit[tracks]": limit_tracks,
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", playlist=playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def get_artist(
|
||||
self,
|
||||
artist_id: str,
|
||||
include: str = "albums,music-videos",
|
||||
views: str = "full-albums,compilation-albums,live-albums,singles,top-songs",
|
||||
limit: int = 100,
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_artist", artist_id=artist_id)
|
||||
|
||||
artist = await self._amp_request(
|
||||
APPLE_MUSIC_ARTIST_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
artist_id=artist_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
"views": views,
|
||||
**{
|
||||
f"limit[{_include}]": limit
|
||||
for _include in [*include.split(","), *views.split(",")]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", artist=artist)
|
||||
|
||||
return artist
|
||||
|
||||
async def get_library_album(
|
||||
self,
|
||||
album_id: str,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_album", album_id=album_id)
|
||||
|
||||
album = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_ALBUM_API_URI.format(
|
||||
album_id=album_id,
|
||||
),
|
||||
{
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", album=album)
|
||||
|
||||
return album
|
||||
|
||||
async def get_library_playlist(
|
||||
self,
|
||||
playlist_id: str,
|
||||
include: str = "tracks",
|
||||
limit: int = 100,
|
||||
extend: str = "extendedAssetUrls",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_library_playlist", playlist_id=playlist_id)
|
||||
|
||||
playlist = await self._amp_request(
|
||||
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI.format(
|
||||
playlist_id=playlist_id,
|
||||
),
|
||||
{
|
||||
"include": include,
|
||||
**{f"limit[{_include}]": limit for _include in include.split(",")},
|
||||
"extend": extend,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", playlist=playlist)
|
||||
|
||||
return playlist
|
||||
|
||||
async def get_search_results(
|
||||
self,
|
||||
term: str,
|
||||
types: str = "songs,music-videos,albums,playlists,artists",
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_search_results", term=term, types=types)
|
||||
|
||||
search_results = await self._amp_request(
|
||||
APPLE_MUSIC_SEARCH_API_URI.format(
|
||||
storefront=self.storefront,
|
||||
),
|
||||
{
|
||||
"term": term,
|
||||
"types": types,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", search_results=search_results)
|
||||
|
||||
return search_results
|
||||
|
||||
async def get_extended_api_data(
|
||||
self,
|
||||
next_uri: str | None,
|
||||
href_uri: str,
|
||||
) -> dict:
|
||||
log = logger.bind(
|
||||
action="extend_api_data", next_uri=next_uri, href_uri=href_uri
|
||||
)
|
||||
|
||||
if not next_uri:
|
||||
log.debug("no_next_uri")
|
||||
return
|
||||
|
||||
href_params = parse_qs(urlparse(href_uri).query)
|
||||
next_params = parse_qs(urlparse(next_uri).query)
|
||||
|
||||
if href_params.get("limit"):
|
||||
limit = int(href_params["limit"][0])
|
||||
else:
|
||||
limit = None
|
||||
|
||||
offset = int(next_params["offset"][0])
|
||||
|
||||
extended_data = await self._amp_request(
|
||||
urlparse(next_uri).path,
|
||||
{
|
||||
"offset": offset,
|
||||
**({"limit": limit} if limit else {}),
|
||||
},
|
||||
)
|
||||
|
||||
log.debug("success", extended_data=extended_data)
|
||||
|
||||
return extended_data
|
||||
|
||||
async def get_webplayback(
|
||||
self,
|
||||
track_id: str,
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_webplayback", track_id=track_id)
|
||||
|
||||
response = None
|
||||
try:
|
||||
response = await self.client.post(
|
||||
APPLE_MUSIC_WEBPLAYBACK_API_URL,
|
||||
json={
|
||||
"salableAdamId": track_id,
|
||||
"language": self.language,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
webplayback = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching webplayback data",
|
||||
content=response.text if response is not None else None,
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
if "dialog" in webplayback:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching webplayback data",
|
||||
content=webplayback["dialog"],
|
||||
)
|
||||
|
||||
log.debug("success", webplayback=webplayback)
|
||||
|
||||
return webplayback
|
||||
|
||||
async def get_license_exchange(
|
||||
self,
|
||||
track_id: str,
|
||||
track_uri: str,
|
||||
challenge: str,
|
||||
key_system: str = "com.widevine.alpha",
|
||||
is_library: bool = False,
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_license_exchange", track_id=track_id)
|
||||
|
||||
response = None
|
||||
try:
|
||||
response = await self.client.post(
|
||||
APPLE_MUSIC_LICENSE_API_URL,
|
||||
json={
|
||||
"challenge": challenge,
|
||||
"key-system": key_system,
|
||||
"uri": track_uri,
|
||||
"adamId": track_id,
|
||||
"isLibrary": is_library,
|
||||
"user-initiated": True,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
license_exchange = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching license exchange data",
|
||||
content=response.text if response is not None else None,
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
if license_exchange.get("status") != 0:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching license exchange data",
|
||||
content=response.text,
|
||||
status_code=response.status_code,
|
||||
)
|
||||
|
||||
log.debug("success", license_exchange=license_exchange)
|
||||
|
||||
return license_exchange
|
||||
@@ -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
|
||||
+26
-162
@@ -1,170 +1,34 @@
|
||||
APPLE_MUSIC_HOMEPAGE_URL = "https://music.apple.com"
|
||||
|
||||
APPLE_MUSIC_COOKIE_DOMAIN = ".music.apple.com"
|
||||
AMP_API_URL = "https://amp-api.music.apple.com"
|
||||
WEBPLAYBACK_API_URL = (
|
||||
|
||||
APPLE_MUSIC_AMP_API_URL = "https://amp-api.music.apple.com"
|
||||
APPLE_MUSIC_ACCOUNT_INFO_API_URI = "/v1/me/account"
|
||||
APPLE_MUSIC_SONG_API_URI = "/v1/catalog/{storefront}/songs/{song_id}"
|
||||
APPLE_MUSIC_MUSIC_VIDEO_API_URI = (
|
||||
"/v1/catalog/{storefront}/music-videos/{music_video_id}"
|
||||
)
|
||||
APPLE_MUSIC_UPLOADED_VIDEO_API_URL = (
|
||||
"/v1/catalog/{storefront}/uploaded-videos/{uploaded_video_id}"
|
||||
)
|
||||
APPLE_MUSIC_ALBUM_API_URI = "/v1/catalog/{storefront}/albums/{album_id}"
|
||||
APPLE_MUSIC_PLAYLIST_API_URI = "/v1/catalog/{storefront}/playlists/{playlist_id}"
|
||||
APPLE_MUSIC_ARTIST_API_URI = "/v1/catalog/{storefront}/artists/{artist_id}"
|
||||
APPLE_MUSIC_LIBRARY_ALBUM_API_URI = "/v1/me/library/albums/{album_id}"
|
||||
APPLE_MUSIC_LIBRARY_PLAYLIST_API_URI = "/v1/me/library/playlists/{playlist_id}"
|
||||
APPLE_MUSIC_SEARCH_API_URI = "/v1/catalog/{storefront}/search"
|
||||
|
||||
APPLE_MUSIC_WEBPLAYBACK_API_URL = (
|
||||
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
|
||||
)
|
||||
LICENSE_API_URL = (
|
||||
|
||||
APPLE_MUSIC_LICENSE_API_URL = (
|
||||
"https://play.itunes.apple.com/WebObjects/MZPlay.woa/wa/acquireWebPlaybackLicense"
|
||||
)
|
||||
|
||||
APPLE_MUSIC_MUSIC_KIT_URL = (
|
||||
"https://music.apple.com/includes/js-cdn/musickit/v3/amp/musickit.js"
|
||||
)
|
||||
|
||||
ITUNES_LOOKUP_API_URL = "https://itunes.apple.com/lookup"
|
||||
ITUNES_PAGE_API_URL = "https://music.apple.com"
|
||||
STOREFRONT_IDS = {
|
||||
"AE": "143481-2,32",
|
||||
"AG": "143540-2,32",
|
||||
"AI": "143538-2,32",
|
||||
"AL": "143575-2,32",
|
||||
"AM": "143524-2,32",
|
||||
"AO": "143564-2,32",
|
||||
"AR": "143505-28,32",
|
||||
"AT": "143445-4,32",
|
||||
"AU": "143460-27,32",
|
||||
"AZ": "143568-2,32",
|
||||
"BB": "143541-2,32",
|
||||
"BE": "143446-2,32",
|
||||
"BF": "143578-2,32",
|
||||
"BG": "143526-2,32",
|
||||
"BH": "143559-2,32",
|
||||
"BJ": "143576-2,32",
|
||||
"BM": "143542-2,32",
|
||||
"BN": "143560-2,32",
|
||||
"BO": "143556-28,32",
|
||||
"BR": "143503-15,32",
|
||||
"BS": "143539-2,32",
|
||||
"BT": "143577-2,32",
|
||||
"BW": "143525-2,32",
|
||||
"BY": "143565-2,32",
|
||||
"BZ": "143555-2,32",
|
||||
"CA": "143455-6,32",
|
||||
"CG": "143582-2,32",
|
||||
"CH": "143459-57,32",
|
||||
"CM": "143574-2,32",
|
||||
"CL": "143483-28,32",
|
||||
"CN": "143465-19,32",
|
||||
"CO": "143501-28,32",
|
||||
"CR": "143495-28,32",
|
||||
"CV": "143580-2,32",
|
||||
"CY": "143557-2,32",
|
||||
"CZ": "143489-2,32",
|
||||
"DE": "143443-4,32",
|
||||
"DK": "143458-2,32",
|
||||
"DM": "143545-2,32",
|
||||
"DO": "143508-28,32",
|
||||
"DZ": "143563-2,32",
|
||||
"EC": "143509-28,32",
|
||||
"EE": "143518-2,32",
|
||||
"EG": "143516-2,32",
|
||||
"ES": "143454-8,32",
|
||||
"FI": "143447-2,32",
|
||||
"FJ": "143583-2,32",
|
||||
"FM": "143591-2,32",
|
||||
"FR": "143442-3,32",
|
||||
"GB": "143444-2,32",
|
||||
"GD": "143546-2,32",
|
||||
"GH": "143573-2,32",
|
||||
"GM": "143584-2,32",
|
||||
"GR": "143448-2,32",
|
||||
"GT": "143504-28,32",
|
||||
"GW": "143585-2,32",
|
||||
"GY": "143553-2,32",
|
||||
"HK": "143463-45,32",
|
||||
"HN": "143510-28,32",
|
||||
"HR": "143494-2,32",
|
||||
"HU": "143482-2,32",
|
||||
"ID": "143476-2,32",
|
||||
"IE": "143449-2,32",
|
||||
"IL": "143491-2,32",
|
||||
"IN": "143467-2,32",
|
||||
"IS": "143558-2,32",
|
||||
"IT": "143450-7,32",
|
||||
"JM": "143511-2,32",
|
||||
"JO": "143528-2,32",
|
||||
"JP": "143462-9,32",
|
||||
"KE": "143529-2,32",
|
||||
"KG": "143586-2,32",
|
||||
"KH": "143579-2,32",
|
||||
"KN": "143548-2,32",
|
||||
"KR": "143466-13,32",
|
||||
"KW": "143493-2,32",
|
||||
"KY": "143544-2,32",
|
||||
"KZ": "143517-2,32",
|
||||
"LA": "143587-2,32",
|
||||
"LB": "143497-2,32",
|
||||
"LC": "143549-2,32",
|
||||
"LK": "143486-2,32",
|
||||
"LR": "143588-2,32",
|
||||
"LT": "143520-2,32",
|
||||
"LU": "143451-2,32",
|
||||
"LV": "143519-2,32",
|
||||
"MD": "143523-2,32",
|
||||
"MG": "143531-2,32",
|
||||
"MK": "143530-2,32",
|
||||
"ML": "143532-2,32",
|
||||
"MN": "143592-2,32",
|
||||
"MO": "143515-45,32",
|
||||
"MR": "143590-2,32",
|
||||
"MS": "143547-2,32",
|
||||
"MT": "143521-2,32",
|
||||
"MU": "143533-2,32",
|
||||
"MW": "143589-2,32",
|
||||
"MX": "143468-28,32",
|
||||
"MY": "143473-2,32",
|
||||
"MZ": "143593-2,32",
|
||||
"NA": "143594-2,32",
|
||||
"NE": "143534-2,32",
|
||||
"NG": "143561-2,32",
|
||||
"NI": "143512-28,32",
|
||||
"NL": "143452-10,32",
|
||||
"NO": "143457-2,32",
|
||||
"NP": "143484-2,32",
|
||||
"NZ": "143461-27,32",
|
||||
"OM": "143562-2,32",
|
||||
"PA": "143485-28,32",
|
||||
"PE": "143507-28,32",
|
||||
"PG": "143597-2,32",
|
||||
"PH": "143474-2,32",
|
||||
"PK": "143477-2,32",
|
||||
"PL": "143478-2,32",
|
||||
"PT": "143453-24,32",
|
||||
"PW": "143595-2,32",
|
||||
"PY": "143513-28,32",
|
||||
"QA": "143498-2,32",
|
||||
"RO": "143487-2,32",
|
||||
"RU": "143469-16,32",
|
||||
"SA": "143479-2,32",
|
||||
"SB": "143601-2,32",
|
||||
"SC": "143599-2,32",
|
||||
"SE": "143456-17,32",
|
||||
"SG": "143464-19,32",
|
||||
"SI": "143499-2,32",
|
||||
"SK": "143496-2,32",
|
||||
"SL": "143600-2,32",
|
||||
"SN": "143535-2,32",
|
||||
"SR": "143554-2,32",
|
||||
"ST": "143598-2,32",
|
||||
"SV": "143506-28,32",
|
||||
"SZ": "143602-2,32",
|
||||
"TC": "143552-2,32",
|
||||
"TD": "143581-2,32",
|
||||
"TH": "143475-2,32",
|
||||
"TJ": "143603-2,32",
|
||||
"TM": "143604-2,32",
|
||||
"TN": "143536-2,32",
|
||||
"TR": "143480-2,32",
|
||||
"TT": "143551-2,32",
|
||||
"TW": "143470-18,32",
|
||||
"TZ": "143572-2,32",
|
||||
"UA": "143492-2,32",
|
||||
"UG": "143537-2,32",
|
||||
"US": "143441-1,32",
|
||||
"UY": "143514-2,32",
|
||||
"UZ": "143566-2,32",
|
||||
"VC": "143550-2,32",
|
||||
"VE": "143502-28,32",
|
||||
"VG": "143543-2,32",
|
||||
"VN": "143471-2,32",
|
||||
"YE": "143571-2,32",
|
||||
"ZA": "143472-2,32",
|
||||
"ZW": "143605-2,32",
|
||||
}
|
||||
ITUNES_PAGE_API_URL = "https://music.apple.com/{media_type}/{media_id}"
|
||||
|
||||
+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,150 @@
|
||||
import re
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from .constants import (
|
||||
APPLE_MUSIC_MUSIC_KIT_URL,
|
||||
ITUNES_LOOKUP_API_URL,
|
||||
ITUNES_PAGE_API_URL,
|
||||
)
|
||||
from .exceptions import GamdlApiResponseError
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class ItunesApi:
|
||||
def __init__(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
storefront: str,
|
||||
language: str,
|
||||
storefront_id: int,
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.storefront = storefront
|
||||
self.language = language
|
||||
self.storefront_id = storefront_id
|
||||
|
||||
@staticmethod
|
||||
async def get_storefront_id(storefront: str) -> int:
|
||||
log = logger.bind(action="get_storefront_id", storefront=storefront)
|
||||
|
||||
response = None
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(APPLE_MUSIC_MUSIC_KIT_URL)
|
||||
response.raise_for_status()
|
||||
music_kit_content = response.text
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching MusicKit content",
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
normalized_storefront = storefront.upper()
|
||||
|
||||
country_code_pattern = f'{normalized_storefront}:"([A-Z]{{3}})"'
|
||||
country_code_match = re.search(country_code_pattern, music_kit_content)
|
||||
if not country_code_match:
|
||||
raise GamdlApiResponseError(
|
||||
f"Country code {storefront} not found in MusicKit content"
|
||||
)
|
||||
|
||||
three_letter_code = country_code_match.group(1)
|
||||
|
||||
storefront_pattern = f'{three_letter_code}:"(\\d+)"'
|
||||
storefront_match = re.search(storefront_pattern, music_kit_content)
|
||||
if not storefront_match:
|
||||
raise GamdlApiResponseError(
|
||||
f"Storefront ID not found for country code {storefront}"
|
||||
)
|
||||
|
||||
storefront_id = int(storefront_match.group(1))
|
||||
|
||||
log.debug("success", storefront_id=storefront_id)
|
||||
|
||||
return storefront_id
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
storefront: str = "us",
|
||||
storefront_id: int | None = 143441,
|
||||
language: str = "en-US",
|
||||
) -> "ItunesApi":
|
||||
storefront_id = storefront_id or await cls.get_storefront_id(storefront)
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
return cls(
|
||||
client=client,
|
||||
storefront=storefront,
|
||||
language=language,
|
||||
storefront_id=storefront_id,
|
||||
)
|
||||
|
||||
async def get_lookup_result(
|
||||
self,
|
||||
media_id: str,
|
||||
entity: str = "album",
|
||||
) -> dict:
|
||||
log = logger.bind(action="get_lookup_result", media_id=media_id, entity=entity)
|
||||
|
||||
response = None
|
||||
try:
|
||||
response = await self.client.get(
|
||||
ITUNES_LOOKUP_API_URL,
|
||||
params={
|
||||
"id": media_id,
|
||||
"entity": entity,
|
||||
"country": self.storefront,
|
||||
"lang": self.language,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
lookup_result = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching iTunes lookup result",
|
||||
content=response.text if response is not None else None,
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
log.debug("success", lookup_result=lookup_result)
|
||||
|
||||
return lookup_result
|
||||
|
||||
async def get_itunes_page(
|
||||
self,
|
||||
media_type: str,
|
||||
media_id: str,
|
||||
) -> dict:
|
||||
log = logger.bind(
|
||||
action="get_itunes_page",
|
||||
media_type=media_type,
|
||||
media_id=media_id,
|
||||
)
|
||||
|
||||
response = None
|
||||
try:
|
||||
response = await self.client.get(
|
||||
ITUNES_PAGE_API_URL.format(media_type=media_type, media_id=media_id),
|
||||
headers={
|
||||
"X-Apple-Store-Front": f"{self.storefront_id}-1,32 t:music31",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
itunes_page = response.json()
|
||||
except httpx.HTTPError:
|
||||
raise GamdlApiResponseError(
|
||||
"Error fetching iTunes page",
|
||||
content=response.text if response is not None else None,
|
||||
status_code=response.status_code if response is not None else None,
|
||||
)
|
||||
|
||||
log.debug("success", itunes_page=itunes_page)
|
||||
|
||||
return itunes_page
|
||||
@@ -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
|
||||
+167
-146
@@ -5,35 +5,42 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
import colorama
|
||||
import structlog
|
||||
from dataclass_click import dataclass_click
|
||||
from httpx import ConnectError
|
||||
|
||||
from .. import __version__
|
||||
from ..api import AppleMusicApi, ItunesApi
|
||||
from ..api import AppleMusicApi
|
||||
from ..downloader import (
|
||||
AppleMusicBaseDownloader,
|
||||
AppleMusicDownloader,
|
||||
AppleMusicMusicVideoDownloader,
|
||||
AppleMusicSongDownloader,
|
||||
AppleMusicUploadedVideoDownloader,
|
||||
DownloadItem,
|
||||
DownloadMode,
|
||||
GamdlError,
|
||||
RemuxMode,
|
||||
GamdlDownloaderDependencyNotFoundError,
|
||||
GamdlDownloaderMediaFileExistsError,
|
||||
GamdlDownloaderSyncedLyricsOnlyError,
|
||||
)
|
||||
from ..interface import (
|
||||
AppleMusicBaseInterface,
|
||||
AppleMusicInterface,
|
||||
AppleMusicMusicVideoInterface,
|
||||
AppleMusicSongInterface,
|
||||
AppleMusicUploadedVideoInterface,
|
||||
SongCodec,
|
||||
GamdlInterfaceArtistMediaTypeError,
|
||||
GamdlInterfaceDecryptionNotAvailableError,
|
||||
GamdlInterfaceFlatFilterExcludedError,
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
GamdlInterfaceUrlParseError,
|
||||
)
|
||||
from .cli_config import CliConfig
|
||||
from .config_file import ConfigFile
|
||||
from .constants import X_NOT_IN_PATH
|
||||
from .utils import CustomLoggerFormatter, prompt_path
|
||||
from .database import Database
|
||||
from .interactive_prompts import InteractivePrompts
|
||||
from .utils import custom_structlog_formatter, prompt_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
def make_sync(func):
|
||||
@@ -58,14 +65,23 @@ async def main(config: CliConfig):
|
||||
root_logger.propagate = False
|
||||
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(CustomLoggerFormatter())
|
||||
stream_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
root_logger.addHandler(stream_handler)
|
||||
|
||||
if config.log_file:
|
||||
file_handler = logging.FileHandler(config.log_file, encoding="utf-8")
|
||||
file_handler.setFormatter(CustomLoggerFormatter(use_colors=False))
|
||||
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.ExceptionPrettyPrinter(),
|
||||
custom_structlog_formatter,
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
)
|
||||
|
||||
logger.info(f"Starting Gamdl {__version__}")
|
||||
|
||||
if config.use_wrapper:
|
||||
@@ -87,128 +103,125 @@ async def main(config: CliConfig):
|
||||
language=config.language,
|
||||
)
|
||||
|
||||
itunes_api = ItunesApi(
|
||||
apple_music_api.storefront,
|
||||
apple_music_api.language,
|
||||
)
|
||||
|
||||
if not apple_music_api.active_subscription:
|
||||
logger.critical(
|
||||
"No active Apple Music subscription found, you won't be able to download"
|
||||
" anything"
|
||||
)
|
||||
return
|
||||
|
||||
if apple_music_api.account_restrictions:
|
||||
logger.warning(
|
||||
"Your account has content restrictions enabled, some content may not be"
|
||||
" downloadable"
|
||||
)
|
||||
|
||||
interface = AppleMusicInterface(
|
||||
apple_music_api,
|
||||
itunes_api,
|
||||
if (
|
||||
any(not codec.is_legacy() for codec in config.song_codec_piority)
|
||||
and not config.use_wrapper
|
||||
):
|
||||
logger.warning(
|
||||
"You have chosen an experimental song codec "
|
||||
"without enabling wrapper. "
|
||||
"They're not guaranteed to work due to API limitations."
|
||||
)
|
||||
|
||||
if config.database_path:
|
||||
database = Database(config.database_path)
|
||||
flat_filter = database.flat_filter
|
||||
else:
|
||||
database = None
|
||||
flat_filter = None
|
||||
|
||||
interactive_prompts = InteractivePrompts(
|
||||
artist_auto_select=config.artist_auto_select,
|
||||
)
|
||||
|
||||
base_interface = await AppleMusicBaseInterface.create(
|
||||
apple_music_api=apple_music_api,
|
||||
cover_format=config.cover_format,
|
||||
cover_size=config.cover_size,
|
||||
use_wrapper=config.use_wrapper,
|
||||
wrapper_m3u8_ip=config.wrapper_m3u8_ip,
|
||||
wvd_path=config.wvd_path,
|
||||
)
|
||||
|
||||
song_interface = AppleMusicSongInterface(
|
||||
base=base_interface,
|
||||
synced_lyrics_format=config.synced_lyrics_format,
|
||||
codec_priority=config.song_codec_piority,
|
||||
use_album_date=config.use_album_date,
|
||||
skip_stream_info=config.synced_lyrics_only,
|
||||
ask_codec_function=interactive_prompts.ask_song_codec,
|
||||
)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(
|
||||
base=base_interface,
|
||||
resolution=config.music_video_resolution,
|
||||
codec_priority=config.music_video_codec_priority,
|
||||
ask_video_codec_function=interactive_prompts.ask_music_video_video_codec_function,
|
||||
ask_audio_codec_function=interactive_prompts.ask_music_video_audio_codec_function,
|
||||
)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(
|
||||
base=base_interface,
|
||||
quality=config.uploaded_video_quality,
|
||||
ask_quality_function=interactive_prompts.ask_uploaded_video_quality_function,
|
||||
)
|
||||
|
||||
interface = AppleMusicInterface(
|
||||
song=song_interface,
|
||||
music_video=music_video_interface,
|
||||
uploaded_video=uploaded_video_interface,
|
||||
artist_select_media_type_function=interactive_prompts.ask_artist_media_type,
|
||||
artist_select_items_function=interactive_prompts.ask_artist_select_items,
|
||||
flat_filter_function=flat_filter,
|
||||
)
|
||||
song_interface = AppleMusicSongInterface(interface)
|
||||
music_video_interface = AppleMusicMusicVideoInterface(interface)
|
||||
uploaded_video_interface = AppleMusicUploadedVideoInterface(interface)
|
||||
|
||||
base_downloader = AppleMusicBaseDownloader(
|
||||
interface=interface,
|
||||
output_path=config.output_path,
|
||||
temp_path=config.temp_path,
|
||||
wvd_path=config.wvd_path,
|
||||
overwrite=config.overwrite,
|
||||
save_cover=config.save_cover,
|
||||
save_playlist=config.save_playlist,
|
||||
nm3u8dlre_path=config.nm3u8dlre_path,
|
||||
mp4decrypt_path=config.mp4decrypt_path,
|
||||
ffmpeg_path=config.ffmpeg_path,
|
||||
mp4box_path=config.mp4box_path,
|
||||
use_wrapper=config.use_wrapper,
|
||||
wrapper_decrypt_ip=config.wrapper_decrypt_ip,
|
||||
download_mode=config.download_mode,
|
||||
remux_mode=config.remux_mode,
|
||||
cover_format=config.cover_format,
|
||||
album_folder_template=config.album_folder_template,
|
||||
compilation_folder_template=config.compilation_folder_template,
|
||||
no_album_folder_template=config.no_album_folder_template,
|
||||
playlist_folder_template=config.playlist_folder_template,
|
||||
single_disc_file_template=config.single_disc_file_template,
|
||||
multi_disc_file_template=config.multi_disc_file_template,
|
||||
no_album_file_template=config.no_album_file_template,
|
||||
playlist_file_template=config.playlist_file_template,
|
||||
date_tag_template=config.date_tag_template,
|
||||
exclude_tags=config.exclude_tags,
|
||||
cover_size=config.cover_size,
|
||||
truncate=config.truncate,
|
||||
)
|
||||
|
||||
song_downloader = AppleMusicSongDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=song_interface,
|
||||
codec=config.song_codec,
|
||||
synced_lyrics_format=config.synced_lyrics_format,
|
||||
no_synced_lyrics=config.no_synced_lyrics,
|
||||
synced_lyrics_only=config.synced_lyrics_only,
|
||||
use_album_date=config.use_album_date,
|
||||
fetch_extra_tags=config.fetch_extra_tags,
|
||||
base=base_downloader,
|
||||
)
|
||||
music_video_downloader = AppleMusicMusicVideoDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=music_video_interface,
|
||||
codec_priority=config.music_video_codec_priority,
|
||||
base=base_downloader,
|
||||
remux_mode=config.music_video_remux_mode,
|
||||
remux_format=config.music_video_remux_format,
|
||||
resolution=config.music_video_resolution,
|
||||
)
|
||||
uploaded_video_downloader = AppleMusicUploadedVideoDownloader(
|
||||
base_downloader=base_downloader,
|
||||
interface=uploaded_video_interface,
|
||||
quality=config.uploaded_video_quality,
|
||||
base=base_downloader,
|
||||
)
|
||||
|
||||
downloader = AppleMusicDownloader(
|
||||
interface=interface,
|
||||
base_downloader=base_downloader,
|
||||
song_downloader=song_downloader,
|
||||
music_video_downloader=music_video_downloader,
|
||||
uploaded_video_downloader=uploaded_video_downloader,
|
||||
song=song_downloader,
|
||||
music_video=music_video_downloader,
|
||||
uploaded_video=uploaded_video_downloader,
|
||||
overwrite=config.overwrite,
|
||||
save_cover=config.save_cover,
|
||||
save_playlist=config.save_playlist,
|
||||
no_synced_lyrics=config.no_synced_lyrics,
|
||||
synced_lyrics_only=config.synced_lyrics_only,
|
||||
)
|
||||
|
||||
if not config.synced_lyrics_only:
|
||||
if not config.use_wrapper:
|
||||
if not base_downloader.full_ffmpeg_path and (
|
||||
config.remux_mode == RemuxMode.FFMPEG
|
||||
or config.download_mode == DownloadMode.NM3U8DLRE
|
||||
):
|
||||
logger.critical(X_NOT_IN_PATH.format("ffmpeg", config.ffmpeg_path))
|
||||
return
|
||||
|
||||
if (
|
||||
not base_downloader.full_mp4box_path
|
||||
and config.remux_mode == RemuxMode.MP4BOX
|
||||
):
|
||||
logger.critical(X_NOT_IN_PATH.format("MP4Box", config.mp4box_path))
|
||||
return
|
||||
|
||||
if not base_downloader.full_mp4decrypt_path and (
|
||||
config.song_codec not in (SongCodec.AAC_LEGACY, SongCodec.AAC_HE_LEGACY)
|
||||
or config.remux_mode == RemuxMode.MP4BOX
|
||||
):
|
||||
logger.critical(
|
||||
X_NOT_IN_PATH.format("mp4decrypt", config.mp4decrypt_path)
|
||||
)
|
||||
return
|
||||
|
||||
if (
|
||||
config.download_mode == DownloadMode.NM3U8DLRE
|
||||
and not base_downloader.full_nm3u8dlre_path
|
||||
):
|
||||
logger.critical(X_NOT_IN_PATH.format("N_m3u8DL-RE", config.nm3u8dlre_path))
|
||||
return
|
||||
|
||||
if not config.song_codec.is_legacy() and not config.use_wrapper:
|
||||
logger.warning(
|
||||
"You have chosen an experimental song codec"
|
||||
" without enabling wrapper."
|
||||
"They're not guaranteed to work due to API limitations."
|
||||
)
|
||||
|
||||
if config.read_urls_as_txt:
|
||||
urls_from_file = []
|
||||
for url in config.urls:
|
||||
@@ -226,68 +239,76 @@ async def main(config: CliConfig):
|
||||
|
||||
error_count = 0
|
||||
for url_index, url in enumerate(urls, 1):
|
||||
url_progress = click.style(f"[URL {url_index}/{len(urls)}]", dim=True)
|
||||
logger.info(url_progress + f' Processing "{url}"')
|
||||
download_queue = None
|
||||
url_log = logger.bind(action=f"URL {url_index:>3}/{len(urls):<3}")
|
||||
|
||||
url_log.info(f'Processing "{url}"')
|
||||
|
||||
try:
|
||||
url_info = downloader.get_url_info(url)
|
||||
if not url_info:
|
||||
logger.warning(
|
||||
url_progress + f' Could not parse "{url}", skipping.',
|
||||
)
|
||||
continue
|
||||
async for download_item in downloader.get_download_item_from_url(url):
|
||||
media_index = download_item.media.index + 1
|
||||
media_total = download_item.media.total or "-"
|
||||
|
||||
download_queue = await downloader.get_download_queue(url_info)
|
||||
if not download_queue:
|
||||
logger.warning(
|
||||
url_progress
|
||||
+ f' No downloadable media found for "{url}", skipping.',
|
||||
track_log = logger.bind(
|
||||
action=f"Track {media_index:>3}/{media_total:<3}"
|
||||
)
|
||||
continue
|
||||
except KeyboardInterrupt:
|
||||
exit(1)
|
||||
|
||||
media_title = (
|
||||
download_item.media.media_metadata["attributes"]["name"]
|
||||
if download_item.media.media_metadata
|
||||
and download_item.media.media_metadata.get("attributes", {}).get(
|
||||
"name"
|
||||
)
|
||||
else "Unknown Title"
|
||||
)
|
||||
media_type = (
|
||||
download_item.media.media_metadata["type"]
|
||||
if download_item.media.media_metadata
|
||||
else None
|
||||
)
|
||||
|
||||
if download_item.media.partial and media_type in {
|
||||
None,
|
||||
"songs",
|
||||
"library-songs",
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
"uploaded-videos",
|
||||
}:
|
||||
track_log.info(f'Downloading "{media_title}"')
|
||||
|
||||
try:
|
||||
await downloader.download(download_item)
|
||||
except (
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceDecryptionNotAvailableError,
|
||||
GamdlInterfaceArtistMediaTypeError,
|
||||
GamdlDownloaderSyncedLyricsOnlyError,
|
||||
GamdlDownloaderMediaFileExistsError,
|
||||
GamdlDownloaderDependencyNotFoundError,
|
||||
GamdlInterfaceFlatFilterExcludedError,
|
||||
) as e:
|
||||
track_log.warning(f'Skipping "{media_title}": {e}')
|
||||
continue
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
track_log.exception(f'Error downloading "{media_title}"')
|
||||
|
||||
if (
|
||||
database
|
||||
and download_item.media.media_metadata
|
||||
and download_item.final_path
|
||||
):
|
||||
database.add(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.final_path,
|
||||
)
|
||||
except GamdlInterfaceUrlParseError as e:
|
||||
url_log.error(f"{e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
url_log.exception(f'Error processing "{url}": {e}')
|
||||
error_count += 1
|
||||
logger.error(
|
||||
url_progress + f' Error processing "{url}"',
|
||||
exc_info=not config.no_exceptions,
|
||||
)
|
||||
|
||||
if not download_queue:
|
||||
continue
|
||||
|
||||
for download_index, download_item in enumerate(
|
||||
download_queue,
|
||||
1,
|
||||
):
|
||||
download_queue_progress = click.style(
|
||||
f"[Track {download_index}/{len(download_queue)}]",
|
||||
dim=True,
|
||||
)
|
||||
media_title = (
|
||||
download_item.media_metadata["attributes"]["name"]
|
||||
if isinstance(
|
||||
download_item,
|
||||
DownloadItem,
|
||||
)
|
||||
else "Unknown Title"
|
||||
)
|
||||
logger.info(download_queue_progress + f' Downloading "{media_title}"')
|
||||
|
||||
try:
|
||||
await downloader.download(download_item)
|
||||
except GamdlError as e:
|
||||
logger.warning(
|
||||
download_queue_progress + f' Skipping "{media_title}": {e}'
|
||||
)
|
||||
continue
|
||||
except KeyboardInterrupt:
|
||||
exit(1)
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
logger.error(
|
||||
download_queue_progress + f' Error downloading "{media_title}"',
|
||||
exc_info=not config.no_exceptions,
|
||||
)
|
||||
|
||||
logger.info(f"Finished with {error_count} error(s)")
|
||||
|
||||
+202
-157
@@ -9,14 +9,19 @@ from dataclass_click import argument, option
|
||||
from ..api import AppleMusicApi
|
||||
from ..downloader import (
|
||||
AppleMusicBaseDownloader,
|
||||
AppleMusicDownloader,
|
||||
AppleMusicMusicVideoDownloader,
|
||||
AppleMusicSongDownloader,
|
||||
AppleMusicUploadedVideoDownloader,
|
||||
DownloadMode,
|
||||
RemuxFormatMusicVideo,
|
||||
RemuxMode,
|
||||
)
|
||||
from ..interface import (
|
||||
AppleMusicBaseInterface,
|
||||
AppleMusicInterface,
|
||||
AppleMusicMusicVideoInterface,
|
||||
AppleMusicSongInterface,
|
||||
AppleMusicUploadedVideoInterface,
|
||||
ArtistMediaType,
|
||||
CoverFormat,
|
||||
MusicVideoCodec,
|
||||
MusicVideoResolution,
|
||||
@@ -28,13 +33,19 @@ from .utils import Csv
|
||||
|
||||
api_from_cookies_sig = inspect.signature(AppleMusicApi.create_from_netscape_cookies)
|
||||
api_from_wrapper_sig = inspect.signature(AppleMusicApi.create_from_wrapper)
|
||||
api_sig = inspect.signature(AppleMusicApi.__init__)
|
||||
api_create_sig = inspect.signature(AppleMusicApi.create)
|
||||
|
||||
base_interface_create_sig = inspect.signature(AppleMusicBaseInterface.create)
|
||||
song_interface_sig = inspect.signature(AppleMusicSongInterface.__init__)
|
||||
music_video_interface_sig = inspect.signature(AppleMusicMusicVideoInterface.__init__)
|
||||
uploaded_video_interface_sig = inspect.signature(
|
||||
AppleMusicUploadedVideoInterface.__init__
|
||||
)
|
||||
interface_create_sig = inspect.signature(AppleMusicInterface)
|
||||
|
||||
base_downloader_sig = inspect.signature(AppleMusicBaseDownloader.__init__)
|
||||
music_video_downloader_sig = inspect.signature(AppleMusicMusicVideoDownloader.__init__)
|
||||
song_downloader_sig = inspect.signature(AppleMusicSongDownloader.__init__)
|
||||
uploaded_video_downloader_sig = inspect.signature(
|
||||
AppleMusicUploadedVideoDownloader.__init__
|
||||
)
|
||||
downloader_sig = inspect.signature(AppleMusicDownloader.__init__)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -102,6 +113,38 @@ 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,
|
||||
),
|
||||
]
|
||||
# API specific options
|
||||
cookies_path: Annotated[
|
||||
str,
|
||||
@@ -132,7 +175,111 @@ class CliConfig:
|
||||
"--language",
|
||||
"-l",
|
||||
help="Metadata language",
|
||||
default=api_sig.parameters["language"].default,
|
||||
default=api_create_sig.parameters["language"].default,
|
||||
),
|
||||
]
|
||||
# Base Interface specific options
|
||||
cover_format: Annotated[
|
||||
CoverFormat,
|
||||
option(
|
||||
"--cover-format",
|
||||
help="Cover format",
|
||||
default=base_interface_create_sig.parameters["cover_format"].default,
|
||||
type=CoverFormat,
|
||||
),
|
||||
]
|
||||
cover_size: Annotated[
|
||||
int,
|
||||
option(
|
||||
"--cover-size",
|
||||
help="Cover size in pixels",
|
||||
default=base_interface_create_sig.parameters["cover_size"].default,
|
||||
),
|
||||
]
|
||||
wvd_path: Annotated[
|
||||
str | None,
|
||||
option(
|
||||
"--wvd-path",
|
||||
help=".wvd file path",
|
||||
default=base_interface_create_sig.parameters["wvd_path"].default,
|
||||
type=click.Path(
|
||||
file_okay=False,
|
||||
dir_okay=True,
|
||||
writable=True,
|
||||
resolve_path=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
use_wrapper: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--use-wrapper",
|
||||
help="Use wrapper for decrypting songs",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
wrapper_m3u8_ip: Annotated[
|
||||
str,
|
||||
option(
|
||||
"--wrapper-m3u8-ip",
|
||||
help="Wrapper m3u8 IP address and port",
|
||||
default=base_interface_create_sig.parameters["wrapper_m3u8_ip"].default,
|
||||
),
|
||||
]
|
||||
# Song Interface Options
|
||||
synced_lyrics_format: Annotated[
|
||||
SyncedLyricsFormat,
|
||||
option(
|
||||
"--synced-lyrics-format",
|
||||
help="Synced lyrics format",
|
||||
default=song_interface_sig.parameters["synced_lyrics_format"].default,
|
||||
type=SyncedLyricsFormat,
|
||||
),
|
||||
]
|
||||
song_codec_piority: Annotated[
|
||||
list[SongCodec],
|
||||
option(
|
||||
"--song-codec-priority",
|
||||
help="Comma-separated codec priority",
|
||||
default=song_interface_sig.parameters["codec_priority"].default,
|
||||
type=Csv(SongCodec),
|
||||
),
|
||||
]
|
||||
use_album_date: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--use-album-date",
|
||||
help="Use album release date for songs",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
# Music Video Interface Options
|
||||
music_video_resolution: Annotated[
|
||||
MusicVideoResolution,
|
||||
option(
|
||||
"--music-video-resolution",
|
||||
help="Max music video resolution",
|
||||
default=music_video_interface_sig.parameters["resolution"].default,
|
||||
type=MusicVideoResolution,
|
||||
),
|
||||
]
|
||||
music_video_codec_priority: Annotated[
|
||||
list[MusicVideoCodec],
|
||||
option(
|
||||
"--music-video-codec-priority",
|
||||
help="Comma-separated codec priority",
|
||||
default=music_video_interface_sig.parameters["codec_priority"].default,
|
||||
type=Csv(MusicVideoCodec),
|
||||
),
|
||||
]
|
||||
# Uploaded Video Interface Options
|
||||
uploaded_video_quality: Annotated[
|
||||
UploadedVideoQuality,
|
||||
option(
|
||||
"--uploaded-video-quality",
|
||||
help="Post video quality",
|
||||
default=uploaded_video_interface_sig.parameters["quality"].default,
|
||||
type=UploadedVideoQuality,
|
||||
),
|
||||
]
|
||||
# Base Downloader specific options
|
||||
@@ -165,45 +312,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(
|
||||
@@ -236,14 +344,6 @@ class CliConfig:
|
||||
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(
|
||||
@@ -261,24 +361,6 @@ class CliConfig:
|
||||
type=DownloadMode,
|
||||
),
|
||||
]
|
||||
remux_mode: Annotated[
|
||||
RemuxMode,
|
||||
option(
|
||||
"--remux-mode",
|
||||
help="Remux mode",
|
||||
default=base_downloader_sig.parameters["remux_mode"].default,
|
||||
type=RemuxMode,
|
||||
),
|
||||
]
|
||||
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(
|
||||
@@ -305,6 +387,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(
|
||||
@@ -354,14 +444,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(
|
||||
@@ -370,23 +452,49 @@ class CliConfig:
|
||||
default=base_downloader_sig.parameters["truncate"].default,
|
||||
),
|
||||
]
|
||||
# DownloaderSong specific options
|
||||
song_codec: Annotated[
|
||||
SongCodec,
|
||||
# DownloaderMusicVideo specific options
|
||||
music_video_remux_mode: Annotated[
|
||||
RemuxMode,
|
||||
option(
|
||||
"--song-codec",
|
||||
help="Song codec",
|
||||
default=song_downloader_sig.parameters["codec"].default,
|
||||
type=SongCodec,
|
||||
"--music-video-remux-mode",
|
||||
help="Remux mode",
|
||||
default=music_video_downloader_sig.parameters["remux_mode"].default,
|
||||
type=RemuxMode,
|
||||
),
|
||||
]
|
||||
synced_lyrics_format: Annotated[
|
||||
SyncedLyricsFormat,
|
||||
music_video_remux_format: Annotated[
|
||||
RemuxFormatMusicVideo,
|
||||
option(
|
||||
"--synced-lyrics-format",
|
||||
help="Synced lyrics format",
|
||||
default=song_downloader_sig.parameters["synced_lyrics_format"].default,
|
||||
type=SyncedLyricsFormat,
|
||||
"--music-video-remux-format",
|
||||
help="Music video remux format",
|
||||
default=music_video_downloader_sig.parameters["remux_format"].default,
|
||||
type=RemuxFormatMusicVideo,
|
||||
),
|
||||
]
|
||||
# Downloader specific options
|
||||
overwrite: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--overwrite",
|
||||
help="Overwrite existing files",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
save_cover: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--save-cover",
|
||||
"-s",
|
||||
help="Save cover as separate file",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
save_playlist: Annotated[
|
||||
bool,
|
||||
option(
|
||||
"--save-playlist",
|
||||
help="Save M3U8 playlist file",
|
||||
is_flag=True,
|
||||
),
|
||||
]
|
||||
no_synced_lyrics: Annotated[
|
||||
@@ -405,66 +513,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_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,48 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, path: Path):
|
||||
self.connection = sqlite3.connect(path)
|
||||
self.cursor = self.connection.cursor()
|
||||
self._create_tables()
|
||||
|
||||
def _create_tables(self) -> None:
|
||||
self.cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
def get(self, media_id: str) -> str | None:
|
||||
self.cursor.execute("SELECT path FROM media WHERE id = ?", (media_id,))
|
||||
row = self.cursor.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def add(self, media_id: str, path: str) -> None:
|
||||
self.cursor.execute(
|
||||
"INSERT OR REPLACE INTO media (id, path) VALUES (?, ?)",
|
||||
(media_id, str(Path(path).absolute())),
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
def remove(self, media_id: str) -> None:
|
||||
self.cursor.execute("DELETE FROM media WHERE id = ?", (media_id,))
|
||||
self.connection.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
self.connection.close()
|
||||
|
||||
def flat_filter(self, media_metadata: dict) -> str | None:
|
||||
media_id = media_metadata["id"]
|
||||
result = self.get(media_id)
|
||||
|
||||
if not result:
|
||||
return None
|
||||
|
||||
return result if Path(result).exists() else None
|
||||
@@ -0,0 +1,232 @@
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
import m3u8
|
||||
from ..interface import ArtistMediaType
|
||||
|
||||
|
||||
class InteractivePrompts:
|
||||
def __init__(
|
||||
self,
|
||||
artist_auto_select: ArtistMediaType | None = None,
|
||||
):
|
||||
self.artist_auto_select = artist_auto_select
|
||||
|
||||
@staticmethod
|
||||
def millis_to_min_sec(millis) -> str:
|
||||
minutes, seconds = divmod(millis // 1000, 60)
|
||||
return f"{minutes:02}:{seconds:02}"
|
||||
|
||||
@staticmethod
|
||||
async def ask_song_codec(
|
||||
playlists: list[dict],
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["stream_info"]["audio"],
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlists
|
||||
]
|
||||
|
||||
return await inquirer.select(
|
||||
message="Select which codec to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
@staticmethod
|
||||
async def ask_music_video_video_codec_function(
|
||||
playlists: list[m3u8.Playlist],
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
playlist.stream_info.codecs[:4],
|
||||
"x".join(str(v) for v in playlist.stream_info.resolution),
|
||||
str(playlist.stream_info.bandwidth),
|
||||
]
|
||||
),
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlists
|
||||
]
|
||||
|
||||
return await inquirer.select(
|
||||
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
@staticmethod
|
||||
async def ask_music_video_audio_codec_function(
|
||||
playlists: list[dict],
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["group_id"],
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlists
|
||||
]
|
||||
|
||||
selected = await inquirer.select(
|
||||
message="Select which audio codec to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
@staticmethod
|
||||
async def ask_uploaded_video_quality_function(
|
||||
available_qualities: dict[str, str],
|
||||
) -> str:
|
||||
qualities = list(available_qualities.keys())
|
||||
choices = [
|
||||
Choice(
|
||||
name=quality,
|
||||
value=quality,
|
||||
)
|
||||
for quality in qualities
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which quality to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return available_qualities[selected]
|
||||
|
||||
async def ask_artist_media_type(
|
||||
self,
|
||||
media_types: list[ArtistMediaType],
|
||||
artist_metadata: dict,
|
||||
) -> ArtistMediaType:
|
||||
if self.artist_auto_select:
|
||||
return self.artist_auto_select
|
||||
|
||||
available_choices = []
|
||||
for media_types in media_types:
|
||||
available_choices.append(
|
||||
Choice(
|
||||
name=str(media_types),
|
||||
value=(media_types,),
|
||||
),
|
||||
)
|
||||
|
||||
(media_type,) = await inquirer.select(
|
||||
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
|
||||
choices=available_choices,
|
||||
validate=lambda result: artist_metadata.get(result[0].path_key[0], {})
|
||||
.get(result[0].path_key[1], {})
|
||||
.get("data"),
|
||||
).execute_async()
|
||||
|
||||
return media_type
|
||||
|
||||
async def ask_artist_select_items(
|
||||
self,
|
||||
media_type: ArtistMediaType,
|
||||
items: list[dict],
|
||||
) -> list[dict]:
|
||||
if media_type in {
|
||||
ArtistMediaType.MAIN_ALBUMS,
|
||||
ArtistMediaType.COMPILATION_ALBUMS,
|
||||
ArtistMediaType.LIVE_ALBUMS,
|
||||
ArtistMediaType.SINGLES_EPS,
|
||||
ArtistMediaType.ALL_ALBUMS,
|
||||
}:
|
||||
return await self._ask_artist_select_albums(items)
|
||||
elif media_type == ArtistMediaType.TOP_SONGS:
|
||||
return await self._ask_artist_select_songs(
|
||||
items,
|
||||
)
|
||||
elif media_type == ArtistMediaType.MUSIC_VIDEOS:
|
||||
return await self._ask_artist_select_music_videos(items)
|
||||
|
||||
async def _ask_artist_select_albums(
|
||||
self,
|
||||
albums: list[dict],
|
||||
) -> list[dict]:
|
||||
if self.artist_auto_select:
|
||||
return albums
|
||||
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
f'{album["attributes"]["trackCount"]:03d}',
|
||||
f'{album["attributes"]["releaseDate"]:<10}',
|
||||
f'{album["attributes"].get("contentRating", "None").title():<8}',
|
||||
f'{album["attributes"]["name"]}',
|
||||
]
|
||||
),
|
||||
value=album,
|
||||
)
|
||||
for album in albums
|
||||
if album.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
async def _ask_artist_select_songs(
|
||||
self,
|
||||
songs: list[dict],
|
||||
) -> list[dict]:
|
||||
if self.artist_auto_select:
|
||||
return songs
|
||||
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(song["attributes"]["durationInMillis"]),
|
||||
f'{song["attributes"].get("contentRating", "None").title():<8}',
|
||||
song["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=song,
|
||||
)
|
||||
for song in songs
|
||||
if song.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which songs to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
async def _ask_artist_select_music_videos(
|
||||
self,
|
||||
music_videos: list[dict],
|
||||
) -> list[dict]:
|
||||
if self.artist_auto_select:
|
||||
return music_videos
|
||||
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(
|
||||
music_video["attributes"]["durationInMillis"]
|
||||
),
|
||||
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
|
||||
music_video["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=music_video,
|
||||
)
|
||||
for music_video in music_videos
|
||||
if music_video.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which music videos to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
+27
-23
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
@@ -38,31 +39,34 @@ class Csv(click.ParamType):
|
||||
return result
|
||||
|
||||
|
||||
class CustomLoggerFormatter(logging.Formatter):
|
||||
base_format = "[%(levelname)-8s %(asctime)s]"
|
||||
format_colors = {
|
||||
logging.DEBUG: dict(dim=True),
|
||||
logging.INFO: dict(fg="green"),
|
||||
logging.WARNING: dict(fg="yellow"),
|
||||
logging.ERROR: dict(fg="red"),
|
||||
logging.CRITICAL: dict(fg="red", bold=True),
|
||||
def custom_structlog_formatter(
|
||||
logger: Any,
|
||||
name: str,
|
||||
event_dict: dict[str, Any],
|
||||
) -> str:
|
||||
level = event_dict.get("level", "INFO").upper()
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
level_colors = {
|
||||
"DEBUG": "cyan",
|
||||
"INFO": "green",
|
||||
"WARNING": "yellow",
|
||||
"ERROR": "red",
|
||||
"CRITICAL": "red",
|
||||
}
|
||||
date_format = "%H:%M:%S"
|
||||
|
||||
def __init__(self, use_colors: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.use_colors = use_colors
|
||||
color = level_colors.get(level, "white")
|
||||
prefix = click.style(f"[{level:<8} {timestamp}]", fg=color)
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return logging.Formatter(
|
||||
(
|
||||
click.style(self.base_format, **self.format_colors.get(record.levelno))
|
||||
if self.use_colors
|
||||
else self.base_format
|
||||
)
|
||||
+ " %(message)s",
|
||||
datefmt=self.date_format,
|
||||
).format(record)
|
||||
action = event_dict.pop("action", None)
|
||||
if action:
|
||||
prefix += click.style(f" [{action}]", dim=True)
|
||||
|
||||
if level in {"INFO", "WARNING", "ERROR", "CRITICAL"}:
|
||||
message = event_dict.get("event", "")
|
||||
return f"{prefix} {message}"
|
||||
else:
|
||||
return f"{prefix} {event_dict}"
|
||||
|
||||
|
||||
def prompt_path(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from .amdecrypt import decrypt_file, decrypt_file_hex
|
||||
from .base import AppleMusicBaseDownloader
|
||||
from .downloader import AppleMusicDownloader
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .downloader_music_video import AppleMusicMusicVideoDownloader
|
||||
from .downloader_song import AppleMusicSongDownloader
|
||||
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
from .enums import *
|
||||
from .exceptions import *
|
||||
from .music_video import AppleMusicMusicVideoDownloader
|
||||
from .song import AppleMusicSongDownloader
|
||||
from .types import *
|
||||
from .uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
|
||||
+640
-23
@@ -7,11 +7,17 @@ import asyncio
|
||||
import io
|
||||
import logging
|
||||
import struct
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import BinaryIO, List, Optional
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default decryption key for songs without per-sample keys (legacy AAC)
|
||||
DEFAULT_SONG_DECRYPTION_KEY = b"2\xb8\xad\xe1v\x9e&\xb1\xff\xb8\x98cRy?\xc6"
|
||||
|
||||
# Pre-fetch key used for first sample description
|
||||
PREFETCH_KEY = "skd://itunes.apple.com/P000000000/s1/e1"
|
||||
|
||||
@@ -26,6 +32,20 @@ class SampleInfo:
|
||||
data: bytes
|
||||
duration: int
|
||||
desc_index: int
|
||||
iv: bytes = b"" # Per-sample IV from senc (empty if constant IV)
|
||||
subsamples: List[tuple] = field(
|
||||
default_factory=list
|
||||
) # [(clear_bytes, encrypted_bytes), ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class EncryptionInfo:
|
||||
"""Encryption scheme info extracted from sinf/schm + sinf/schi/tenc."""
|
||||
|
||||
scheme_type: str = "cbcs" # 'cenc' or 'cbcs'
|
||||
per_sample_iv_size: int = 0 # 0, 8, or 16
|
||||
constant_iv: bytes = b"" # Constant IV (when per_sample_iv_size == 0)
|
||||
kid: bytes = b"" # Default Key ID (16 bytes)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -35,6 +55,7 @@ class SongInfo:
|
||||
samples: List[SampleInfo] = field(default_factory=list)
|
||||
moov_data: bytes = b""
|
||||
ftyp_data: bytes = b""
|
||||
encryption_info: Optional[EncryptionInfo] = None
|
||||
|
||||
|
||||
def read_box_header(f: BinaryIO) -> tuple[int, str, int]:
|
||||
@@ -137,16 +158,36 @@ def extract_song(input_path: str) -> SongInfo:
|
||||
elif box["type"] == "moov":
|
||||
song_info.moov_data = box["data"]
|
||||
|
||||
# Get default sample info from trex (inside moov)
|
||||
default_sample_duration = 1024
|
||||
default_sample_size = 0
|
||||
|
||||
# Determine which track is the audio track
|
||||
audio_track_id = (
|
||||
_extract_audio_track_id(song_info.moov_data) if song_info.moov_data else 1
|
||||
)
|
||||
logger.debug(f"Audio track ID: {audio_track_id}")
|
||||
|
||||
# Get default sample info from trex (inside moov/mvex)
|
||||
trex_defaults = (
|
||||
_extract_trex_defaults(song_info.moov_data, audio_track_id)
|
||||
if song_info.moov_data
|
||||
else None
|
||||
)
|
||||
if trex_defaults:
|
||||
default_sample_duration = trex_defaults["default_sample_duration"]
|
||||
default_sample_size = trex_defaults["default_sample_size"]
|
||||
else:
|
||||
# Fallback defaults. ALAC typically uses 4096 samples per frame,
|
||||
# while AAC uses 1024. Default to 4096 if the track contains 'alac'.
|
||||
is_alac = song_info.moov_data and b"alac" in song_info.moov_data
|
||||
default_sample_duration = 4096 if is_alac else 1024
|
||||
default_sample_size = 0
|
||||
logger.debug(
|
||||
f"Default sample duration: {default_sample_duration}, "
|
||||
f"default sample size: {default_sample_size}"
|
||||
)
|
||||
|
||||
# Extract encryption scheme info from moov (sinf/schm + sinf/schi/tenc)
|
||||
if song_info.moov_data:
|
||||
song_info.encryption_info = _extract_encryption_info(song_info.moov_data)
|
||||
|
||||
# Parse moof/mdat pairs
|
||||
moof_box = None
|
||||
for box in boxes:
|
||||
@@ -158,6 +199,11 @@ def extract_song(input_path: str) -> SongInfo:
|
||||
mdat_data = box["data"][box["header_size"] :] # Skip mdat header
|
||||
|
||||
# Parse moof for tfhd (sample description index, defaults) and trun (entries)
|
||||
_iv_size = (
|
||||
song_info.encryption_info.per_sample_iv_size
|
||||
if song_info.encryption_info
|
||||
else 0
|
||||
)
|
||||
samples_from_pair = _parse_moof_mdat(
|
||||
moof_data,
|
||||
mdat_data,
|
||||
@@ -166,10 +212,23 @@ def extract_song(input_path: str) -> SongInfo:
|
||||
audio_track_id=audio_track_id,
|
||||
moof_offset=moof_box["offset"],
|
||||
mdat_data_offset=box["offset"] + box["header_size"],
|
||||
per_sample_iv_size=_iv_size,
|
||||
)
|
||||
song_info.samples.extend(samples_from_pair)
|
||||
moof_box = None
|
||||
|
||||
# Post-process samples: if this is ALAC, ensure all samples have duration 4096.
|
||||
# Apple Music fragments often report 1024 in trex/tfhd defaults, but
|
||||
# ALAC frames are actually 4096 samples long. This mismatch is the
|
||||
# root cause of the 1:16 duration reporting for 5-minute tracks.
|
||||
is_alac = song_info.moov_data and (b"alac" in song_info.moov_data or b"ALAC" in song_info.moov_data)
|
||||
if is_alac:
|
||||
logger.debug("ALAC detected: forcing all sample durations to 4096")
|
||||
for sample in song_info.samples:
|
||||
# Only override if it was 0 or the common incorrect default of 1024
|
||||
if sample.duration in (0, 1024):
|
||||
sample.duration = 4096
|
||||
|
||||
logger.debug(f"Extracted {len(song_info.samples)} samples from {input_path}")
|
||||
return song_info
|
||||
|
||||
@@ -182,6 +241,7 @@ def _parse_moof_mdat(
|
||||
audio_track_id: int = 1,
|
||||
moof_offset: int = 0,
|
||||
mdat_data_offset: int = 0,
|
||||
per_sample_iv_size: int = 0,
|
||||
) -> List[SampleInfo]:
|
||||
"""Parse a moof box and extract samples from corresponding mdat.
|
||||
|
||||
@@ -192,6 +252,7 @@ def _parse_moof_mdat(
|
||||
audio_track_id: Track ID of the audio track to extract.
|
||||
moof_offset: Absolute file offset of the moof box.
|
||||
mdat_data_offset: Absolute file offset of the mdat content (after header).
|
||||
per_sample_iv_size: IV size per sample from tenc (0, 8, or 16).
|
||||
"""
|
||||
samples = []
|
||||
|
||||
@@ -216,6 +277,7 @@ def _parse_moof_mdat(
|
||||
}
|
||||
trun_entries = []
|
||||
first_trun_data_offset = None
|
||||
senc_entries = [] # Per-sample encryption info from senc box
|
||||
|
||||
traf_offset = offset + 8
|
||||
traf_end = offset + size
|
||||
@@ -241,6 +303,11 @@ def _parse_moof_mdat(
|
||||
if first_trun_data_offset is None:
|
||||
first_trun_data_offset = data_off
|
||||
trun_entries.extend(entries)
|
||||
elif inner_type == "senc":
|
||||
senc_entries = _parse_senc(
|
||||
moof_data[traf_offset + 8 : traf_offset + inner_size],
|
||||
per_sample_iv_size,
|
||||
)
|
||||
|
||||
traf_offset += inner_size
|
||||
|
||||
@@ -264,17 +331,26 @@ def _parse_moof_mdat(
|
||||
if desc_index > 0:
|
||||
desc_index -= 1 # Convert to 0-indexed
|
||||
|
||||
for entry in trun_entries:
|
||||
for i, entry in enumerate(trun_entries):
|
||||
sample_size = entry.get("size", tfhd_info["default_size"])
|
||||
sample_duration = entry.get("duration", tfhd_info["default_duration"])
|
||||
|
||||
if sample_size > 0 and mdat_read_offset + sample_size <= len(mdat_data):
|
||||
# Attach per-sample encryption info from senc if available
|
||||
sample_iv = b""
|
||||
sample_subsamples = []
|
||||
if i < len(senc_entries):
|
||||
sample_iv = senc_entries[i]["iv"]
|
||||
sample_subsamples = senc_entries[i]["subsamples"]
|
||||
|
||||
sample = SampleInfo(
|
||||
data=mdat_data[
|
||||
mdat_read_offset : mdat_read_offset + sample_size
|
||||
],
|
||||
duration=sample_duration,
|
||||
desc_index=desc_index,
|
||||
iv=sample_iv,
|
||||
subsamples=sample_subsamples,
|
||||
)
|
||||
samples.append(sample)
|
||||
mdat_read_offset += sample_size
|
||||
@@ -357,6 +433,56 @@ def _parse_trun(data: bytes, tfhd_info: dict) -> tuple[List[dict], Optional[int]
|
||||
return entries, data_offset_value
|
||||
|
||||
|
||||
def _parse_senc(data: bytes, per_sample_iv_size: int) -> List[dict]:
|
||||
"""Parse Sample Encryption Box (senc) content (after box header).
|
||||
|
||||
Returns a list of dicts, one per sample:
|
||||
{"iv": bytes, "subsamples": [(clear_bytes, encrypted_bytes), ...]}
|
||||
|
||||
The data starts after the 8-byte box header (size+type) but includes
|
||||
the FullBox header (version 1 byte + flags 3 bytes).
|
||||
|
||||
per_sample_iv_size can be 0 (cbcs with constant IV from tenc) — in that case
|
||||
there are 0 IV bytes per sample but subsample info may still be present.
|
||||
"""
|
||||
if len(data) < 8:
|
||||
return []
|
||||
|
||||
version = data[0]
|
||||
flags = struct.unpack(">I", b"\x00" + data[1:4])[0]
|
||||
sample_count = struct.unpack(">I", data[4:8])[0]
|
||||
|
||||
entries: List[dict] = []
|
||||
offset = 8
|
||||
for _ in range(sample_count):
|
||||
# Read per-sample IV (0 bytes when per_sample_iv_size == 0)
|
||||
iv = b""
|
||||
if per_sample_iv_size > 0:
|
||||
if offset + per_sample_iv_size > len(data):
|
||||
break
|
||||
iv = data[offset : offset + per_sample_iv_size]
|
||||
offset += per_sample_iv_size
|
||||
|
||||
subsamples = []
|
||||
if flags & 0x02:
|
||||
# Subsample encryption info present
|
||||
if offset + 2 > len(data):
|
||||
break
|
||||
subsample_count = struct.unpack(">H", data[offset : offset + 2])[0]
|
||||
offset += 2
|
||||
for _ in range(subsample_count):
|
||||
if offset + 6 > len(data):
|
||||
break
|
||||
clear_bytes = struct.unpack(">H", data[offset : offset + 2])[0]
|
||||
encrypted_bytes = struct.unpack(">I", data[offset + 2 : offset + 6])[0]
|
||||
subsamples.append((clear_bytes, encrypted_bytes))
|
||||
offset += 6
|
||||
|
||||
entries.append({"iv": iv, "subsamples": subsamples})
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
async def decrypt_samples(
|
||||
wrapper_ip: str,
|
||||
track_id: str,
|
||||
@@ -380,8 +506,6 @@ async def decrypt_samples(
|
||||
Args:
|
||||
progress_callback: Optional callback(current_sample, total_samples, bytes_processed) for progress tracking
|
||||
"""
|
||||
import time
|
||||
|
||||
host, port = wrapper_ip.split(":")
|
||||
port = int(port)
|
||||
|
||||
@@ -491,10 +615,12 @@ def write_decrypted_m4a(
|
||||
orig_mvhd = None
|
||||
orig_tkhd = None
|
||||
orig_mdhd = None
|
||||
orig_hdlr = None
|
||||
orig_smhd = None
|
||||
orig_dinf = None
|
||||
timescale = 44100 # Default
|
||||
# We will use the actual audio sample rate from the stsd as our
|
||||
# master timescale to ensure 100% duration consistency.
|
||||
orig_hdlr = None
|
||||
timescale = 44100 # Default fallback
|
||||
|
||||
if original_path:
|
||||
with open(original_path, "rb") as f:
|
||||
@@ -506,7 +632,8 @@ def write_decrypted_m4a(
|
||||
|
||||
if orig_data:
|
||||
stsd_content = _extract_stsd_content(orig_data)
|
||||
timescale = _extract_timescale(orig_data)
|
||||
# Extract the REAL sample rate from the codec configuration
|
||||
timescale = _extract_sample_rate_from_stsd(stsd_content) or _extract_timescale(orig_data)
|
||||
|
||||
# Find moov box and extract child boxes
|
||||
moov_idx = orig_data.find(b"moov")
|
||||
@@ -607,7 +734,7 @@ def _write_moov(
|
||||
|
||||
# mvhd (movie header)
|
||||
if orig_mvhd:
|
||||
f.write(_patch_mvhd_duration(orig_mvhd, total_duration))
|
||||
f.write(_patch_mvhd_duration(orig_mvhd, total_duration, timescale))
|
||||
else:
|
||||
mvhd_content = struct.pack(">II", 0, 0) # creation, modification
|
||||
mvhd_content += struct.pack(">I", timescale)
|
||||
@@ -650,7 +777,7 @@ def _write_moov(
|
||||
|
||||
# mdhd (media header) - preserves original language code
|
||||
if orig_mdhd:
|
||||
f.write(_patch_mdhd_duration(orig_mdhd, total_duration))
|
||||
f.write(_patch_mdhd_duration(orig_mdhd, total_duration, timescale))
|
||||
else:
|
||||
mdhd_content = struct.pack(">II", 0, 0) # creation, modification
|
||||
mdhd_content += struct.pack(">I", timescale)
|
||||
@@ -740,6 +867,23 @@ def _write_moov(
|
||||
f.write(struct.pack(">I", mdat_offset))
|
||||
f.seek(0, 2) # Back to end
|
||||
|
||||
def _extract_sample_rate_from_stsd(stsd_content: bytes) -> Optional[int]:
|
||||
"""Extract the actual audio sample rate from the stsd box content."""
|
||||
# Header: version(1)+flags(3)+count(4) + Entry: size(4)+type(4) = 16 bytes
|
||||
# AudioSampleEntry v0: reserved(6)+dref(2)+ver(2)+rev(2)+vend(4)+chan(2)+size(2)+comp(2)+pack(2)+rate(4)
|
||||
# The fixed-point sample_rate field is at offset 16 + 24 = 40.
|
||||
if not stsd_content or len(stsd_content) < 44:
|
||||
return None
|
||||
|
||||
samplerate_offset = 40
|
||||
sample_rate_fixed = struct.unpack(">I", stsd_content[samplerate_offset : samplerate_offset + 4])[0]
|
||||
sample_rate = sample_rate_fixed >> 16
|
||||
|
||||
# Sanity check: standard audio sample rates should be between 8000 and 384000
|
||||
if 8000 <= sample_rate <= 384000:
|
||||
return sample_rate
|
||||
return None
|
||||
|
||||
|
||||
def _write_stsd(f, stsd_content: bytes):
|
||||
"""Write sample description box using content from original file.
|
||||
@@ -1083,10 +1227,16 @@ def _extract_timescale(data: bytes) -> int:
|
||||
"""Extract timescale from moov/mvhd or mdhd box."""
|
||||
# Look for mdhd box (media header has the audio timescale)
|
||||
idx = data.find(b"mdhd")
|
||||
if idx > 0 and idx + 24 < len(data):
|
||||
# mdhd: version(1) + flags(3) + creation(4) + modification(4) + timescale(4)
|
||||
return struct.unpack(">I", data[idx + 16 : idx + 20])[0]
|
||||
return 44100 # Default
|
||||
if idx > 0 and idx + 28 < len(data):
|
||||
# mdhd: size(4) + type(b'mdhd') + version(1) + flags(3)
|
||||
version = data[idx + 4]
|
||||
if version == 0:
|
||||
# v0: ver+flags(4) + creation(4) + modification(4) + timescale(4)
|
||||
return struct.unpack(">I", data[idx + 16 : idx + 20])[0]
|
||||
else:
|
||||
# v1: ver+flags(4) + creation(8) + modification(8) + timescale(4)
|
||||
return struct.unpack(">I", data[idx + 24 : idx + 28])[0]
|
||||
return 44100 # Default fallback
|
||||
|
||||
|
||||
def _find_child_box(
|
||||
@@ -1139,15 +1289,17 @@ def _find_audio_trak(moov_data: bytes) -> Optional[bytes]:
|
||||
return None
|
||||
|
||||
|
||||
def _patch_mvhd_duration(box_data: bytes, duration: int) -> bytes:
|
||||
"""Return a copy of the mvhd box with its duration field patched."""
|
||||
def _patch_mvhd_duration(box_data: bytes, duration: int, timescale: int) -> bytes:
|
||||
"""Return a copy of the mvhd box with its duration and timescale fields patched."""
|
||||
data = bytearray(box_data)
|
||||
version = data[8] # After size(4) + type(4)
|
||||
if version == 0:
|
||||
# v0: ver+flags(4) + creation(4) + modification(4) + timescale(4) + duration(4)
|
||||
struct.pack_into(">I", data, 20, timescale)
|
||||
struct.pack_into(">I", data, 24, duration)
|
||||
else:
|
||||
# v1: ver+flags(4) + creation(8) + modification(8) + timescale(4) + duration(8)
|
||||
struct.pack_into(">I", data, 28, timescale)
|
||||
struct.pack_into(">Q", data, 32, duration)
|
||||
return bytes(data)
|
||||
|
||||
@@ -1167,17 +1319,18 @@ def _patch_tkhd_duration(box_data: bytes, duration: int) -> bytes:
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def _patch_mdhd_duration(box_data: bytes, duration: int) -> bytes:
|
||||
"""Return a copy of the mdhd box with its duration field patched.
|
||||
|
||||
Preserves the original language code and all other fields.
|
||||
"""
|
||||
def _patch_mdhd_duration(box_data: bytes, duration: int, timescale: int) -> bytes:
|
||||
"""Return a copy of the mdhd box with its duration and timescale fields patched."""
|
||||
data = bytearray(box_data)
|
||||
version = data[8]
|
||||
if version == 0:
|
||||
# Same layout as mvhd v0
|
||||
# v0: ver+flags(4) + creation(4) + modification(4) + timescale(4) + duration(4)
|
||||
struct.pack_into(">I", data, 20, timescale)
|
||||
struct.pack_into(">I", data, 24, duration)
|
||||
else:
|
||||
# v1: ver+flags(4) + creation(8) + modification(8) + timescale(4) + duration(8)
|
||||
struct.pack_into(">I", data, 28, timescale)
|
||||
struct.pack_into(">Q", data, 32, duration)
|
||||
return bytes(data)
|
||||
|
||||
@@ -1210,6 +1363,263 @@ def _write_udta(f):
|
||||
_fixup_box_size(f, udta_start, b"udta")
|
||||
|
||||
|
||||
def _extract_trex_defaults(moov_data: bytes, target_track_id: int = 0) -> dict:
|
||||
"""Extract default sample values from moov/mvex/trex box.
|
||||
|
||||
The trex (Track Extends) box provides default values for sample duration,
|
||||
size, description index, and flags used by track fragments (traf/trun)
|
||||
when those fields are not explicitly present.
|
||||
|
||||
Args:
|
||||
moov_data: Raw bytes of the moov box.
|
||||
target_track_id: If > 0, only return defaults for this track.
|
||||
If 0, return the first trex found.
|
||||
|
||||
Returns:
|
||||
Dict with keys: default_sample_duration, default_sample_size,
|
||||
default_sample_description_index, default_sample_flags.
|
||||
"""
|
||||
# Determine fallback duration based on codec
|
||||
# ALAC frames are 4096 samples, AAC frames are 1024 samples
|
||||
is_alac = b"alac" in moov_data or b"ALAC" in moov_data
|
||||
fallback_duration = 4096 if is_alac else 1024
|
||||
|
||||
defaults = {
|
||||
"default_sample_duration": fallback_duration,
|
||||
"default_sample_size": 0,
|
||||
"default_sample_description_index": 1,
|
||||
"default_sample_flags": 0,
|
||||
}
|
||||
|
||||
# Find mvex box inside moov
|
||||
mvex = _find_child_box(moov_data, b"mvex")
|
||||
if mvex is None:
|
||||
return defaults
|
||||
|
||||
# Iterate trex children inside mvex
|
||||
offset = 8 # Skip mvex box header
|
||||
while offset + 8 <= len(mvex):
|
||||
size = struct.unpack(">I", mvex[offset : offset + 4])[0]
|
||||
box_type = mvex[offset + 4 : offset + 8]
|
||||
if size < 8 or offset + size > len(mvex):
|
||||
break
|
||||
if box_type == b"trex" and size >= 32:
|
||||
# trex FullBox: size(4) + type(4) + version(1) + flags(3)
|
||||
# + track_id(4) + default_sample_description_index(4)
|
||||
# + default_sample_duration(4) + default_sample_size(4)
|
||||
# + default_sample_flags(4)
|
||||
trex_data = mvex[offset : offset + size]
|
||||
track_id = struct.unpack(">I", trex_data[12:16])[0]
|
||||
if target_track_id == 0 or track_id == target_track_id:
|
||||
defaults["default_sample_description_index"] = struct.unpack(
|
||||
">I", trex_data[16:20]
|
||||
)[0]
|
||||
|
||||
# Extract duration and protect against Apple's dummy values
|
||||
parsed_duration = struct.unpack(">I", trex_data[20:24])[0]
|
||||
|
||||
# Override if the provider wrote 0, or if they incorrectly wrote 1024 for an ALAC track
|
||||
if parsed_duration == 0 or (is_alac and parsed_duration == 1024):
|
||||
defaults["default_sample_duration"] = fallback_duration
|
||||
else:
|
||||
defaults["default_sample_duration"] = parsed_duration
|
||||
|
||||
defaults["default_sample_size"] = struct.unpack(">I", trex_data[24:28])[0]
|
||||
defaults["default_sample_flags"] = struct.unpack(
|
||||
">I", trex_data[28:32]
|
||||
)[0]
|
||||
logger.debug(
|
||||
f"trex defaults for track {track_id}: "
|
||||
f"duration={defaults['default_sample_duration']}, "
|
||||
f"size={defaults['default_sample_size']}"
|
||||
)
|
||||
break
|
||||
offset += size
|
||||
|
||||
return defaults
|
||||
|
||||
def _extract_encryption_info(moov_data: bytes) -> Optional[EncryptionInfo]:
|
||||
"""Extract encryption scheme info from the audio track's sinf box.
|
||||
|
||||
Walks moov → trak (audio) → mdia → minf → stbl → stsd → sample_entry → sinf,
|
||||
then reads sinf/schm for scheme_type and sinf/schi/tenc for IV size, constant IV,
|
||||
and default KID.
|
||||
|
||||
Returns EncryptionInfo or None if no sinf is found.
|
||||
"""
|
||||
trak_data = _find_audio_trak(moov_data)
|
||||
if trak_data is None:
|
||||
return None
|
||||
|
||||
# Navigate trak → mdia → minf → stbl → stsd
|
||||
mdia = _find_child_box(trak_data, b"mdia")
|
||||
if mdia is None:
|
||||
return None
|
||||
minf = _find_child_box(mdia, b"minf")
|
||||
if minf is None:
|
||||
return None
|
||||
stbl = _find_child_box(minf, b"stbl")
|
||||
if stbl is None:
|
||||
return None
|
||||
stsd = _find_child_box(stbl, b"stsd")
|
||||
if stsd is None:
|
||||
return None
|
||||
|
||||
# stsd is a FullBox: 4 (size) + 4 (type) + 4 (version+flags) + 4 (entry_count)
|
||||
# Then the first sample entry immediately follows
|
||||
if len(stsd) < 16:
|
||||
return None
|
||||
entry_offset = 16 # past header+version+flags+entry_count
|
||||
if entry_offset + 8 > len(stsd):
|
||||
return None
|
||||
entry_size = struct.unpack(">I", stsd[entry_offset : entry_offset + 4])[0]
|
||||
entry_data = stsd[entry_offset : entry_offset + entry_size]
|
||||
|
||||
# Find sinf inside this sample entry
|
||||
# Audio sample entries have a 36-byte fixed header:
|
||||
# size(4) + type(4) + reserved(6) + data_ref_index(2) + audio_data(20)
|
||||
# Child boxes (including sinf) start at offset 36
|
||||
sinf = _find_child_box(entry_data, b"sinf", skip_header=36)
|
||||
if sinf is None:
|
||||
return None
|
||||
|
||||
info = EncryptionInfo()
|
||||
|
||||
# Parse schm (Scheme Type Box) inside sinf
|
||||
schm = _find_child_box(sinf, b"schm")
|
||||
if schm and len(schm) >= 20:
|
||||
# schm: 4(size) + 4(type) + 4(ver+flags) + 4(scheme_type) + 4(scheme_version)
|
||||
info.scheme_type = schm[12:16].decode("ascii", errors="replace")
|
||||
logger.debug(f"Encryption scheme: {info.scheme_type}")
|
||||
|
||||
# Parse tenc (Track Encryption Box) inside sinf/schi
|
||||
schi = _find_child_box(sinf, b"schi")
|
||||
if schi:
|
||||
tenc = _find_child_box(schi, b"tenc")
|
||||
if tenc and len(tenc) >= 32:
|
||||
# tenc FullBox layout (offsets include 8-byte box header):
|
||||
# [0:4] size
|
||||
# [4:8] type "tenc"
|
||||
# [8] version
|
||||
# [9:12] flags (3 bytes)
|
||||
# [12] reserved
|
||||
# [13] reserved (v0) / crypt_byte_block|skip_byte_block (v1)
|
||||
# [14] default_isProtected
|
||||
# [15] default_Per_Sample_IV_Size
|
||||
# [16:32] default_KID (16 bytes)
|
||||
# if per_sample_iv_size==0:
|
||||
# [32] default_constant_IV_size
|
||||
# [33..] default_constant_IV
|
||||
tenc_version = tenc[8]
|
||||
per_sample_iv_size = tenc[15]
|
||||
kid = tenc[16:32]
|
||||
|
||||
info.per_sample_iv_size = per_sample_iv_size
|
||||
info.kid = kid
|
||||
logger.debug(
|
||||
f"tenc: per_sample_iv_size={per_sample_iv_size}, " f"kid={kid.hex()}"
|
||||
)
|
||||
|
||||
# If per_sample_iv_size is 0, a constant IV follows the KID
|
||||
if per_sample_iv_size == 0 and len(tenc) > 32:
|
||||
constant_iv_size = tenc[32]
|
||||
if len(tenc) >= 33 + constant_iv_size:
|
||||
info.constant_iv = tenc[33 : 33 + constant_iv_size]
|
||||
logger.debug(f"Constant IV: {info.constant_iv.hex()}")
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def _extract_encryption_info_per_stsd(moov_data: bytes) -> Optional[dict]:
|
||||
"""Extract encryption scheme info for each stsd entry (sample description).
|
||||
|
||||
Returns a dict mapping desc_index (0-based) → EncryptionInfo, or None if no
|
||||
encryption found. This handles cases where different sample descriptions have
|
||||
different encryption parameters (e.g., different IVs or key schemes).
|
||||
"""
|
||||
trak_data = _find_audio_trak(moov_data)
|
||||
if trak_data is None:
|
||||
return None
|
||||
|
||||
# Navigate trak → mdia → minf → stbl → stsd
|
||||
mdia = _find_child_box(trak_data, b"mdia")
|
||||
if mdia is None:
|
||||
return None
|
||||
minf = _find_child_box(mdia, b"minf")
|
||||
if minf is None:
|
||||
return None
|
||||
stbl = _find_child_box(minf, b"stbl")
|
||||
if stbl is None:
|
||||
return None
|
||||
stsd = _find_child_box(stbl, b"stsd")
|
||||
if stsd is None:
|
||||
return None
|
||||
|
||||
if len(stsd) < 16:
|
||||
return None
|
||||
|
||||
entry_count = struct.unpack(">I", stsd[12:16])[0]
|
||||
if entry_count == 0:
|
||||
return None
|
||||
|
||||
encryption_info_per_desc = {}
|
||||
entry_offset = 16 # past header+version+flags+entry_count
|
||||
|
||||
for desc_idx in range(entry_count):
|
||||
if entry_offset + 8 > len(stsd):
|
||||
break
|
||||
|
||||
entry_size = struct.unpack(">I", stsd[entry_offset : entry_offset + 4])[0]
|
||||
if entry_size < 8 or entry_offset + entry_size > len(stsd):
|
||||
break
|
||||
|
||||
entry_data = stsd[entry_offset : entry_offset + entry_size]
|
||||
|
||||
# Find sinf inside this sample entry
|
||||
# Audio sample entries have 36-byte fixed header before child boxes
|
||||
sinf = _find_child_box(entry_data, b"sinf", skip_header=36)
|
||||
if sinf is not None:
|
||||
# Extract encryption info for this stsd entry
|
||||
info = EncryptionInfo()
|
||||
|
||||
# Parse schm
|
||||
schm = _find_child_box(sinf, b"schm")
|
||||
if schm and len(schm) >= 20:
|
||||
info.scheme_type = schm[12:16].decode("ascii", errors="replace")
|
||||
logger.debug(
|
||||
f"Encryption scheme for desc_index {desc_idx}: {info.scheme_type}"
|
||||
)
|
||||
|
||||
# Parse tenc
|
||||
schi = _find_child_box(sinf, b"schi")
|
||||
if schi:
|
||||
tenc = _find_child_box(schi, b"tenc")
|
||||
if tenc and len(tenc) >= 32:
|
||||
per_sample_iv_size = tenc[15]
|
||||
kid = tenc[16:32]
|
||||
|
||||
info.per_sample_iv_size = per_sample_iv_size
|
||||
info.kid = kid
|
||||
logger.debug(
|
||||
f"tenc (desc {desc_idx}): per_sample_iv_size={per_sample_iv_size}"
|
||||
)
|
||||
|
||||
# If per_sample_iv_size is 0, extract constant IV
|
||||
if per_sample_iv_size == 0 and len(tenc) > 32:
|
||||
constant_iv_size = tenc[32]
|
||||
if len(tenc) >= 33 + constant_iv_size:
|
||||
info.constant_iv = tenc[33 : 33 + constant_iv_size]
|
||||
logger.debug(
|
||||
f"Constant IV (desc {desc_idx}): {info.constant_iv.hex()}"
|
||||
)
|
||||
|
||||
encryption_info_per_desc[desc_idx] = info
|
||||
|
||||
entry_offset += entry_size
|
||||
|
||||
return encryption_info_per_desc if encryption_info_per_desc else None
|
||||
|
||||
|
||||
def _extract_audio_track_id(moov_data: bytes) -> int:
|
||||
"""Extract the track ID of the audio track from the moov box.
|
||||
|
||||
@@ -1302,3 +1712,210 @@ async def decrypt_file(
|
||||
decrypted_data,
|
||||
input_path, # Pass original path for codec info extraction
|
||||
)
|
||||
|
||||
|
||||
def decrypt_samples_hex(
|
||||
samples: List[SampleInfo],
|
||||
keys: dict,
|
||||
encryption_info: EncryptionInfo,
|
||||
encryption_info_per_desc: Optional[dict] = None,
|
||||
) -> bytes:
|
||||
"""Decrypt samples using hex AES keys (no wrapper needed).
|
||||
|
||||
Supports both CENC (AES-128-CTR) and CBCS (AES-128-CBC) schemes.
|
||||
|
||||
Args:
|
||||
samples: List of SampleInfo with data, desc_index, iv, subsamples.
|
||||
keys: Mapping of desc_index (int) → AES key (16 bytes, raw).
|
||||
encryption_info: EncryptionInfo with scheme_type, constant_iv, etc.
|
||||
encryption_info_per_desc: Optional dict mapping desc_index → EncryptionInfo
|
||||
(used when different stsd entries have different params).
|
||||
|
||||
Returns:
|
||||
Concatenated decrypted sample data.
|
||||
"""
|
||||
is_cenc = encryption_info.scheme_type == "cenc"
|
||||
decrypted = bytearray()
|
||||
|
||||
for sample in samples:
|
||||
key = keys.get(sample.desc_index)
|
||||
if key is None:
|
||||
# No key for this desc_index — keep data as-is (shouldn't happen)
|
||||
decrypted.extend(sample.data)
|
||||
continue
|
||||
|
||||
# Get encryption info for this sample's desc_index (if per-description info exists)
|
||||
if encryption_info_per_desc and sample.desc_index in encryption_info_per_desc:
|
||||
enc_info = encryption_info_per_desc[sample.desc_index]
|
||||
else:
|
||||
enc_info = encryption_info
|
||||
|
||||
if is_cenc:
|
||||
# AES-128-CTR: per-sample IV from senc, zero-padded to 16 bytes
|
||||
iv = sample.iv
|
||||
if len(iv) < 16:
|
||||
iv = iv + b"\x00" * (16 - len(iv))
|
||||
cipher = AES.new(key, AES.MODE_CTR, nonce=b"", initial_value=iv)
|
||||
|
||||
if sample.subsamples:
|
||||
plaintext = bytearray()
|
||||
offset = 0
|
||||
for clear_bytes, encrypted_bytes in sample.subsamples:
|
||||
plaintext.extend(sample.data[offset : offset + clear_bytes])
|
||||
offset += clear_bytes
|
||||
plaintext.extend(
|
||||
cipher.decrypt(sample.data[offset : offset + encrypted_bytes])
|
||||
)
|
||||
offset += encrypted_bytes
|
||||
plaintext.extend(sample.data[offset:])
|
||||
decrypted.extend(plaintext)
|
||||
else:
|
||||
decrypted.extend(cipher.decrypt(sample.data))
|
||||
|
||||
else:
|
||||
# CBCS (AES-128-CBC): constant IV or per-sample IV
|
||||
iv = sample.iv if sample.iv else enc_info.constant_iv
|
||||
if len(iv) < 16:
|
||||
iv = iv + b"\x00" * (16 - len(iv))
|
||||
|
||||
if sample.subsamples:
|
||||
# For CBCS subsamples: concatenate all encrypted regions into one,
|
||||
# decrypt as one CBC stream (to maintain cipher state), then split back.
|
||||
# This avoids losing bytes if encrypted_bytes values aren't 16-byte aligned.
|
||||
|
||||
# Collect all encrypted byte ranges and encrypt content
|
||||
encrypted_concat = bytearray()
|
||||
subsample_sizes = (
|
||||
[]
|
||||
) # Track size of each encrypted region for reassembly
|
||||
offset = 0
|
||||
for clear_bytes, encrypted_bytes in sample.subsamples:
|
||||
offset += clear_bytes
|
||||
if encrypted_bytes > 0:
|
||||
encrypted_concat.extend(
|
||||
sample.data[offset : offset + encrypted_bytes]
|
||||
)
|
||||
subsample_sizes.append(encrypted_bytes)
|
||||
offset += encrypted_bytes
|
||||
|
||||
# Decrypt concatenated regions as one CBC stream
|
||||
total_enc_len = len(encrypted_concat)
|
||||
decrypted_concat = bytearray()
|
||||
if total_enc_len > 0:
|
||||
cbc_len = total_enc_len & ~0xF
|
||||
if cbc_len > 0:
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
|
||||
decrypted_concat.extend(
|
||||
cipher.decrypt(bytes(encrypted_concat[:cbc_len]))
|
||||
)
|
||||
# Any trailing unaligned bytes (shouldn't happen if file is well-formed)
|
||||
if cbc_len < total_enc_len:
|
||||
decrypted_concat.extend(encrypted_concat[cbc_len:])
|
||||
|
||||
# Reassemble with original clear/encrypted pattern
|
||||
plaintext = bytearray()
|
||||
dec_offset = 0
|
||||
offset = 0
|
||||
for clear_bytes, encrypted_bytes in sample.subsamples:
|
||||
plaintext.extend(sample.data[offset : offset + clear_bytes])
|
||||
offset += clear_bytes
|
||||
if encrypted_bytes > 0:
|
||||
plaintext.extend(
|
||||
decrypted_concat[dec_offset : dec_offset + encrypted_bytes]
|
||||
)
|
||||
dec_offset += encrypted_bytes
|
||||
offset += encrypted_bytes
|
||||
plaintext.extend(sample.data[offset:])
|
||||
decrypted.extend(plaintext)
|
||||
else:
|
||||
# Full subsample: for well-formed files, the entire sample should be
|
||||
# a multiple of 16 bytes. Only truncate if misaligned (unexpected).
|
||||
sample_len = len(sample.data)
|
||||
if sample_len % 16 == 0:
|
||||
# Data is properly 16-byte aligned, decrypt as-is
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
|
||||
decrypted.extend(cipher.decrypt(sample.data))
|
||||
else:
|
||||
# Data is not aligned (unexpected case) - truncate carefully
|
||||
truncated_len = sample_len & ~0xF
|
||||
if truncated_len > 0:
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
|
||||
decrypted.extend(cipher.decrypt(sample.data[:truncated_len]))
|
||||
# Keep unaligned tail bytes as clear (unencrypted)
|
||||
decrypted.extend(sample.data[truncated_len:])
|
||||
else:
|
||||
# Less than 16 bytes - cannot decrypt with CBC, keep as-is
|
||||
decrypted.extend(sample.data)
|
||||
|
||||
logger.debug(
|
||||
f"Decrypted {len(samples)} samples ({len(decrypted)} bytes) with hex keys"
|
||||
)
|
||||
return bytes(decrypted)
|
||||
|
||||
|
||||
async def decrypt_file_hex(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str,
|
||||
legacy: bool = False,
|
||||
) -> None:
|
||||
"""Decrypt an encrypted MP4 file using a hex AES key (no wrapper/mp4decrypt).
|
||||
|
||||
This replaces the mp4decrypt + remux pipeline with pure-Python decryption:
|
||||
1. Extract samples and encryption info from MP4
|
||||
2. Decrypt samples using AES (CTR for cenc / CBC for cbcs)
|
||||
3. Write clean decrypted M4A output
|
||||
|
||||
Args:
|
||||
input_path: Path to encrypted MP4 file.
|
||||
output_path: Path for decrypted output file.
|
||||
decryption_key: Hex-encoded 128-bit AES key (32 hex chars).
|
||||
legacy: If True, treat as legacy AAC (cenc, single key).
|
||||
"""
|
||||
logger.debug(f"Hex-key decrypt: {input_path} -> {output_path}")
|
||||
|
||||
# Extract samples (run in thread to not block)
|
||||
song_info = await asyncio.to_thread(extract_song, input_path)
|
||||
|
||||
# Build key mapping: desc_index → raw AES key bytes
|
||||
track_key = bytes.fromhex(decryption_key)
|
||||
|
||||
if legacy:
|
||||
# Legacy AAC (cenc): single key for all samples (all desc_index 0)
|
||||
keys = {0: track_key}
|
||||
else:
|
||||
# Non-legacy (cbcs): two sample descriptions
|
||||
# desc_index 0 → DEFAULT_SONG_DECRYPTION_KEY (prefetch samples)
|
||||
# desc_index 1 → track key (from Widevine CDM)
|
||||
keys = {0: DEFAULT_SONG_DECRYPTION_KEY, 1: track_key}
|
||||
|
||||
# Use encryption info from the file (fall back to sensible defaults)
|
||||
enc_info = song_info.encryption_info or EncryptionInfo(
|
||||
scheme_type="cenc" if legacy else "cbcs"
|
||||
)
|
||||
|
||||
# Try to extract per-description encryption info (for non-legacy files)
|
||||
# This handles cases where desc_index 0 and 1 have different encryption parameters
|
||||
enc_info_per_desc = None
|
||||
if song_info.moov_data and not legacy:
|
||||
enc_info_per_desc = await asyncio.to_thread(
|
||||
_extract_encryption_info_per_stsd, song_info.moov_data
|
||||
)
|
||||
if enc_info_per_desc:
|
||||
logger.debug(
|
||||
f"Found per-description encryption info: {list(enc_info_per_desc.keys())}"
|
||||
)
|
||||
|
||||
# Decrypt
|
||||
decrypted_data = decrypt_samples_hex(
|
||||
song_info.samples, keys, enc_info, enc_info_per_desc
|
||||
)
|
||||
|
||||
# Write output (preserves original metadata boxes)
|
||||
await asyncio.to_thread(
|
||||
write_decrypted_m4a,
|
||||
output_path,
|
||||
song_info,
|
||||
decrypted_data,
|
||||
input_path,
|
||||
)
|
||||
|
||||
@@ -1,124 +1,85 @@
|
||||
import asyncio
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from pywidevine import Cdm, Device
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from ..interface.interface import AppleMusicInterface
|
||||
from ..interface.types import MediaTags, PlaylistTags
|
||||
from ..utils import CustomStringFormatter, async_subprocess
|
||||
from .constants import ILLEGAL_CHAR_REPLACEMENT, ILLEGAL_CHARS_RE, TEMP_PATH_TEMPLATE
|
||||
from .enums import DownloadMode, RemuxMode
|
||||
from .hardcoded_wvd import HARDCODED_WVD
|
||||
from .enums import DownloadMode
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicBaseDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
interface: AppleMusicInterface,
|
||||
output_path: str = "./Apple Music",
|
||||
temp_path: str = ".",
|
||||
wvd_path: str = None,
|
||||
overwrite: bool = False,
|
||||
save_cover: bool = False,
|
||||
save_playlist: bool = False,
|
||||
nm3u8dlre_path: str = "N_m3u8DL-RE",
|
||||
mp4decrypt_path: str = "mp4decrypt",
|
||||
ffmpeg_path: str = "ffmpeg",
|
||||
mp4box_path: str = "MP4Box",
|
||||
use_wrapper: bool = False,
|
||||
wrapper_decrypt_ip: str = "127.0.0.1:10020",
|
||||
download_mode: DownloadMode = DownloadMode.YTDLP,
|
||||
remux_mode: RemuxMode = RemuxMode.FFMPEG,
|
||||
cover_format: CoverFormat = CoverFormat.JPG,
|
||||
album_folder_template: str = "{album_artist}/{album}",
|
||||
compilation_folder_template: str = "Compilations/{album}",
|
||||
no_album_folder_template: str = "{artist}/Unknown Album",
|
||||
playlist_folder_template: str = "Playlists/{playlist_artist}",
|
||||
single_disc_file_template: str = "{track:02d} {title}",
|
||||
multi_disc_file_template: str = "{disc}-{track:02d} {title}",
|
||||
no_album_file_template: str = "{title}",
|
||||
playlist_file_template: str = "Playlists/{playlist_artist}/{playlist_title}",
|
||||
playlist_file_template: str = "{playlist_title}",
|
||||
date_tag_template: str = "%Y-%m-%dT%H:%M:%SZ",
|
||||
exclude_tags: list[str] = None,
|
||||
cover_size: int = 1200,
|
||||
truncate: int = None,
|
||||
silent: bool = False,
|
||||
):
|
||||
self.interface = interface
|
||||
self.output_path = output_path
|
||||
self.temp_path = temp_path
|
||||
self.wvd_path = wvd_path
|
||||
self.overwrite = overwrite
|
||||
self.save_cover = save_cover
|
||||
self.save_playlist = save_playlist
|
||||
self.nm3u8dlre_path = nm3u8dlre_path
|
||||
self.mp4decrypt_path = mp4decrypt_path
|
||||
self.ffmpeg_path = ffmpeg_path
|
||||
self.mp4box_path = mp4box_path
|
||||
self.use_wrapper = use_wrapper
|
||||
self.wrapper_decrypt_ip = wrapper_decrypt_ip
|
||||
self.download_mode = download_mode
|
||||
self.remux_mode = remux_mode
|
||||
self.cover_format = cover_format
|
||||
self.album_folder_template = album_folder_template
|
||||
self.compilation_folder_template = compilation_folder_template
|
||||
self.no_album_folder_template = no_album_folder_template
|
||||
self.single_disc_file_template = single_disc_file_template
|
||||
self.multi_disc_file_template = multi_disc_file_template
|
||||
self.playlist_folder_template = playlist_folder_template
|
||||
self.no_album_file_template = no_album_file_template
|
||||
self.playlist_file_template = playlist_file_template
|
||||
self.date_tag_template = date_tag_template
|
||||
self.exclude_tags = exclude_tags
|
||||
self.cover_size = cover_size
|
||||
self.truncate = truncate
|
||||
self.silent = silent
|
||||
self.initialize()
|
||||
|
||||
def initialize(self):
|
||||
self._initialize_binary_paths()
|
||||
self._initialize_cdm()
|
||||
|
||||
def _initialize_binary_paths(self):
|
||||
log = logger.bind(action="initialize_binary_paths")
|
||||
|
||||
self.full_nm3u8dlre_path = shutil.which(self.nm3u8dlre_path)
|
||||
self.full_mp4decrypt_path = shutil.which(self.mp4decrypt_path)
|
||||
self.full_ffmpeg_path = shutil.which(self.ffmpeg_path)
|
||||
self.full_mp4box_path = shutil.which(self.mp4box_path)
|
||||
|
||||
def _initialize_cdm(self):
|
||||
if self.wvd_path:
|
||||
self.cdm = Cdm.from_device(Device.load(self.wvd_path))
|
||||
else:
|
||||
self.cdm = Cdm.from_device(Device.loads(HARDCODED_WVD))
|
||||
self.cdm.MAX_NUM_OF_SESSIONS = float("inf")
|
||||
|
||||
def get_random_uuid(self) -> str:
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
def is_media_streamable(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
) -> bool:
|
||||
return bool(media_metadata["attributes"].get("playParams"))
|
||||
|
||||
def get_playlist_tags(
|
||||
self,
|
||||
playlist_metadata: dict,
|
||||
media_metadata: dict,
|
||||
) -> PlaylistTags:
|
||||
playlist_track = (
|
||||
playlist_metadata["relationships"]["tracks"]["data"].index(media_metadata)
|
||||
+ 1
|
||||
)
|
||||
|
||||
return PlaylistTags(
|
||||
playlist_artist=playlist_metadata["attributes"].get(
|
||||
"curatorName", "Unknown"
|
||||
),
|
||||
playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
|
||||
playlist_title=playlist_metadata["attributes"]["name"],
|
||||
playlist_track=playlist_track,
|
||||
log = log.debug(
|
||||
"success",
|
||||
full_nm3u8dlre_path=self.full_nm3u8dlre_path,
|
||||
full_mp4decrypt_path=self.full_mp4decrypt_path,
|
||||
full_ffmpeg_path=self.full_ffmpeg_path,
|
||||
full_mp4box_path=self.full_mp4box_path,
|
||||
)
|
||||
|
||||
def get_temp_path(
|
||||
@@ -128,13 +89,19 @@ class AppleMusicBaseDownloader:
|
||||
file_tag: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(
|
||||
log = logger.bind(action="get_temp_path")
|
||||
|
||||
temp_path = str(
|
||||
Path(self.temp_path)
|
||||
/ TEMP_PATH_TEMPLATE.format(folder_tag)
|
||||
/ (f"{media_id}_{file_tag}" + file_extension)
|
||||
)
|
||||
|
||||
def sanitize_string(
|
||||
log.debug("success", temp_path=temp_path)
|
||||
|
||||
return temp_path
|
||||
|
||||
def _sanitize_string(
|
||||
self,
|
||||
dirty_string: str,
|
||||
file_ext: str = None,
|
||||
@@ -162,6 +129,8 @@ class AppleMusicBaseDownloader:
|
||||
file_extension: str,
|
||||
playlist_tags: PlaylistTags | None,
|
||||
) -> str:
|
||||
log = logger.bind(action="get_final_path")
|
||||
|
||||
if tags.album:
|
||||
template_folder_parts = (
|
||||
self.compilation_folder_template.split("/")
|
||||
@@ -199,7 +168,7 @@ class AppleMusicBaseDownloader:
|
||||
disc_total=(tags.disc_total, ""),
|
||||
media_type=(tags.media_type, "Unknown Media Type"),
|
||||
playlist_artist=(
|
||||
(playlist_tags.playlist_artist if playlist_tags else None),
|
||||
(playlist_tags.artist if playlist_tags else None),
|
||||
"Unknown Playlist Artist",
|
||||
),
|
||||
playlist_id=(
|
||||
@@ -207,11 +176,11 @@ class AppleMusicBaseDownloader:
|
||||
"Unknown Playlist ID",
|
||||
),
|
||||
playlist_title=(
|
||||
(playlist_tags.playlist_title if playlist_tags else None),
|
||||
(playlist_tags.title if playlist_tags else None),
|
||||
"Unknown Playlist Title",
|
||||
),
|
||||
playlist_track=(
|
||||
(playlist_tags.playlist_track if playlist_tags else None),
|
||||
(playlist_tags.track if playlist_tags else None),
|
||||
"",
|
||||
),
|
||||
title=(tags.title, "Unknown Title"),
|
||||
@@ -219,29 +188,39 @@ class AppleMusicBaseDownloader:
|
||||
track=(tags.track, ""),
|
||||
track_total=(tags.track_total, ""),
|
||||
)
|
||||
sanitized_formatted_part = self.sanitize_string(
|
||||
sanitized_formatted_part = self._sanitize_string(
|
||||
formatted_part,
|
||||
file_extension if not is_folder else None,
|
||||
)
|
||||
formatted_parts.append(sanitized_formatted_part)
|
||||
|
||||
return str(Path(self.output_path, *formatted_parts))
|
||||
final_path = str(Path(self.output_path, *formatted_parts))
|
||||
|
||||
log.debug("success", final_path=final_path)
|
||||
|
||||
return final_path
|
||||
|
||||
async def download_stream(self, stream_url: str, download_path: str):
|
||||
log = logger.bind(
|
||||
action="download_stream", stream_url=stream_url, download_path=download_path
|
||||
)
|
||||
|
||||
if self.download_mode == DownloadMode.YTDLP:
|
||||
await self.download_ytdlp(stream_url, download_path)
|
||||
await self._download_ytdlp_async(stream_url, download_path)
|
||||
|
||||
if self.download_mode == DownloadMode.NM3U8DLRE:
|
||||
await self.download_nm3u8dlre(stream_url, download_path)
|
||||
await self._download_nm3u8dlre(stream_url, download_path)
|
||||
|
||||
async def download_ytdlp(self, stream_url: str, download_path: str) -> None:
|
||||
log.debug("success")
|
||||
|
||||
async def _download_ytdlp_async(self, stream_url: str, download_path: str) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._download_ytdlp,
|
||||
self._download_ytdlp_sync,
|
||||
stream_url,
|
||||
download_path,
|
||||
)
|
||||
|
||||
def _download_ytdlp(self, stream_url: str, download_path: str) -> None:
|
||||
def _download_ytdlp_sync(self, stream_url: str, download_path: str) -> None:
|
||||
with YoutubeDL(
|
||||
{
|
||||
"quiet": True,
|
||||
@@ -256,7 +235,7 @@ class AppleMusicBaseDownloader:
|
||||
) as ydl:
|
||||
ydl.download(stream_url)
|
||||
|
||||
async def download_nm3u8dlre(self, stream_url: str, download_path: str):
|
||||
async def _download_nm3u8dlre(self, stream_url: str, download_path: str):
|
||||
download_path_obj = Path(download_path)
|
||||
|
||||
download_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -280,11 +259,12 @@ class AppleMusicBaseDownloader:
|
||||
|
||||
async def apply_tags(
|
||||
self,
|
||||
media_path: Path,
|
||||
media_path: str,
|
||||
tags: MediaTags,
|
||||
cover_bytes: bytes | None,
|
||||
extra_tags: dict | None = None,
|
||||
):
|
||||
log = logger.bind(action="apply_tags", media_path=media_path)
|
||||
|
||||
exclude_tags = self.exclude_tags or []
|
||||
|
||||
filtered_tags = MediaTags(
|
||||
@@ -299,21 +279,21 @@ class AppleMusicBaseDownloader:
|
||||
skip_tagging = "all" in exclude_tags
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.apply_mp4_tags,
|
||||
self._apply_mp4_tags,
|
||||
media_path,
|
||||
mp4_tags,
|
||||
cover_bytes,
|
||||
skip_tagging,
|
||||
extra_tags,
|
||||
)
|
||||
|
||||
def apply_mp4_tags(
|
||||
log.debug("success")
|
||||
|
||||
def _apply_mp4_tags(
|
||||
self,
|
||||
media_path: Path,
|
||||
media_path: str,
|
||||
tags: dict,
|
||||
cover_bytes: bytes | None,
|
||||
skip_tagging: bool,
|
||||
extra_tags: dict | None,
|
||||
):
|
||||
mp4 = MP4(media_path)
|
||||
mp4.clear()
|
||||
@@ -325,14 +305,12 @@ class AppleMusicBaseDownloader:
|
||||
data=cover_bytes,
|
||||
imageformat=(
|
||||
MP4Cover.FORMAT_JPEG
|
||||
if self.cover_format == CoverFormat.JPG
|
||||
if self.interface.base.cover_format == CoverFormat.JPG
|
||||
else MP4Cover.FORMAT_PNG
|
||||
),
|
||||
)
|
||||
]
|
||||
mp4.update(tags)
|
||||
if extra_tags:
|
||||
mp4.update(extra_tags)
|
||||
|
||||
mp4.save()
|
||||
|
||||
@@ -349,82 +327,41 @@ class AppleMusicBaseDownloader:
|
||||
data=cover_bytes,
|
||||
imageformat=(
|
||||
MP4Cover.FORMAT_JPEG
|
||||
if self.cover_format == CoverFormat.JPG
|
||||
if self.interface.base.cover_format == CoverFormat.JPG
|
||||
else MP4Cover.FORMAT_PNG
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
def move_to_final_path(self, stage_path: str, final_path: str) -> None:
|
||||
Path(final_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(stage_path, final_path)
|
||||
|
||||
def write_cover_image(
|
||||
self,
|
||||
cover_bytes: bytes,
|
||||
cover_path: str,
|
||||
) -> None:
|
||||
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(cover_path).write_bytes(cover_bytes)
|
||||
|
||||
def get_playlist_file_path(
|
||||
self,
|
||||
tags: PlaylistTags,
|
||||
) -> str:
|
||||
log = logger.bind(action="get_playlist_file_path")
|
||||
|
||||
template_folder_parts = self.playlist_folder_template.split("/")
|
||||
template_file_parts = self.playlist_file_template.split("/")
|
||||
template_parts = template_folder_parts + template_file_parts
|
||||
formatted_parts = []
|
||||
|
||||
for i, part in enumerate(template_file_parts):
|
||||
is_folder = i < len(template_file_parts) - 1
|
||||
for i, part in enumerate(template_parts):
|
||||
is_folder = i < len(template_parts) - 1
|
||||
formatted_part = CustomStringFormatter().format(
|
||||
part,
|
||||
playlist_artist=(tags.playlist_artist, "Unknown Playlist Artist"),
|
||||
playlist_artist=(tags.artist, "Unknown Playlist Artist"),
|
||||
playlist_id=(tags.playlist_id, "Unknown Playlist ID"),
|
||||
playlist_title=(tags.playlist_title, "Unknown Playlist Title"),
|
||||
playlist_track=(tags.playlist_track, ""),
|
||||
playlist_title=(tags.title, "Unknown Playlist Title"),
|
||||
playlist_track=(tags.track, ""),
|
||||
)
|
||||
file_ext = None if is_folder else ".m3u8"
|
||||
sanitized_formatted_part = self.sanitize_string(
|
||||
file_ext = None if is_folder else ".m3u"
|
||||
sanitized_formatted_part = self._sanitize_string(
|
||||
formatted_part,
|
||||
file_ext,
|
||||
)
|
||||
formatted_parts.append(sanitized_formatted_part)
|
||||
|
||||
return str(Path(self.output_path, *formatted_parts))
|
||||
final_path = str(Path(self.output_path, *formatted_parts))
|
||||
|
||||
def update_playlist_file(
|
||||
self,
|
||||
playlist_file_path: str,
|
||||
final_path: str,
|
||||
playlist_track: int,
|
||||
) -> None:
|
||||
playlist_file_path_obj = Path(playlist_file_path)
|
||||
final_path_obj = Path(final_path)
|
||||
output_dir_obj = Path(self.output_path)
|
||||
log.debug("success", playlist_file_path=final_path)
|
||||
|
||||
playlist_file_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
playlist_file_path_parent_parts_len = len(playlist_file_path_obj.parent.parts)
|
||||
output_path_parts_len = len(output_dir_obj.parts)
|
||||
|
||||
final_path_relative = Path(
|
||||
("../" * (playlist_file_path_parent_parts_len - output_path_parts_len)),
|
||||
*final_path_obj.parts[output_path_parts_len:],
|
||||
)
|
||||
playlist_file_lines = (
|
||||
playlist_file_path_obj.open("r", encoding="utf8").readlines()
|
||||
if playlist_file_path_obj.exists()
|
||||
else []
|
||||
)
|
||||
if len(playlist_file_lines) < playlist_track:
|
||||
playlist_file_lines.extend(
|
||||
"\n" for _ in range(playlist_track - len(playlist_file_lines))
|
||||
)
|
||||
|
||||
playlist_file_lines[playlist_track - 1] = final_path_relative.as_posix() + "\n"
|
||||
with playlist_file_path_obj.open("w", encoding="utf8") as playlist_file:
|
||||
playlist_file.writelines(playlist_file_lines)
|
||||
|
||||
def cleanup_temp(self, random_uuid: str) -> None:
|
||||
temp_folder = Path(self.temp_path) / TEMP_PATH_TEMPLATE.format(random_uuid)
|
||||
if temp_folder.exists():
|
||||
shutil.rmtree(temp_folder)
|
||||
return final_path
|
||||
@@ -1,28 +1,3 @@
|
||||
import re
|
||||
|
||||
DEFAULT_SONG_DECRYPTION_KEY = "32b8ade1769e26b1ffb8986352793fc6"
|
||||
TEMP_PATH_TEMPLATE = "gamdl_temp_{}"
|
||||
ILLEGAL_CHARS_RE = r'[\\/:*?"<>|;]'
|
||||
ILLEGAL_CHAR_REPLACEMENT = "_"
|
||||
|
||||
SONG_MEDIA_TYPE = {"song", "songs", "library-songs"}
|
||||
ALBUM_MEDIA_TYPE = {"album", "albums", "library-albums"}
|
||||
MUSIC_VIDEO_MEDIA_TYPE = {"music-video", "music-videos", "library-music-videos"}
|
||||
ARTIST_MEDIA_TYPE = {"artist", "artists", "library-artists"}
|
||||
UPLOADED_VIDEO_MEDIA_TYPE = {"post", "uploaded-videos"}
|
||||
PLAYLIST_MEDIA_TYPE = {"playlist", "playlists", "library-playlists"}
|
||||
|
||||
VALID_URL_PATTERN = re.compile(
|
||||
r"https://music\.apple\.com"
|
||||
r"(?:"
|
||||
r"/(?P<storefront>[a-z]{2})"
|
||||
r"/(?P<type>artist|album|playlist|song|music-video|post)"
|
||||
r"(?:/(?P<slug>[^\s/]+))?"
|
||||
r"/(?P<id>[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]+)"
|
||||
r"(?:\?i=(?P<sub_id>[0-9]+))?"
|
||||
r"|"
|
||||
r"(?:/(?P<library_storefront>[a-z]{2}))?"
|
||||
r"/library/(?P<library_type>playlist|albums)"
|
||||
r"/(?P<library_id>p\.[a-zA-Z0-9]+|l\.[a-zA-Z0-9]+)"
|
||||
r")"
|
||||
)
|
||||
|
||||
+209
-527
@@ -1,586 +1,268 @@
|
||||
import asyncio
|
||||
import typing
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
import structlog
|
||||
|
||||
from ..interface import AppleMusicInterface
|
||||
from ..utils import safe_gather
|
||||
from .constants import (
|
||||
ALBUM_MEDIA_TYPE,
|
||||
ARTIST_MEDIA_TYPE,
|
||||
MUSIC_VIDEO_MEDIA_TYPE,
|
||||
PLAYLIST_MEDIA_TYPE,
|
||||
SONG_MEDIA_TYPE,
|
||||
UPLOADED_VIDEO_MEDIA_TYPE,
|
||||
VALID_URL_PATTERN,
|
||||
)
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .downloader_music_video import AppleMusicMusicVideoDownloader
|
||||
from .downloader_song import AppleMusicSongDownloader
|
||||
from .downloader_uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
from ..interface.types import AppleMusicMedia
|
||||
from .constants import TEMP_PATH_TEMPLATE
|
||||
from .enums import DownloadMode, RemuxMode
|
||||
from .exceptions import (
|
||||
ExecutableNotFound,
|
||||
FormatNotAvailable,
|
||||
MediaFileExists,
|
||||
NotStreamable,
|
||||
SyncedLyricsOnly,
|
||||
UnsupportedMediaType,
|
||||
GamdlDownloaderDependencyNotFoundError,
|
||||
GamdlDownloaderMediaFileExistsError,
|
||||
GamdlDownloaderSyncedLyricsOnlyError,
|
||||
)
|
||||
from .types import DownloadItem, UrlInfo
|
||||
from .music_video import AppleMusicMusicVideoDownloader
|
||||
from .song import AppleMusicSongDownloader
|
||||
from .types import DownloadItem
|
||||
from .uploaded_video import AppleMusicUploadedVideoDownloader
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
interface: AppleMusicInterface,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
song_downloader: AppleMusicSongDownloader,
|
||||
music_video_downloader: AppleMusicMusicVideoDownloader,
|
||||
uploaded_video_downloader: AppleMusicUploadedVideoDownloader,
|
||||
skip_music_videos: bool = False,
|
||||
song: AppleMusicSongDownloader,
|
||||
music_video: AppleMusicMusicVideoDownloader,
|
||||
uploaded_video: AppleMusicUploadedVideoDownloader,
|
||||
overwrite: bool = False,
|
||||
save_cover: bool = False,
|
||||
save_playlist: bool = False,
|
||||
no_synced_lyrics: bool = False,
|
||||
synced_lyrics_only: bool = False,
|
||||
skip_cleanup: bool = False,
|
||||
skip_processing: bool = False,
|
||||
flat_filter: typing.Callable = None,
|
||||
):
|
||||
self.interface = interface
|
||||
self.base_downloader = base_downloader
|
||||
self.song_downloader = song_downloader
|
||||
self.music_video_downloader = music_video_downloader
|
||||
self.uploaded_video_downloader = uploaded_video_downloader
|
||||
self.skip_music_videos = skip_music_videos
|
||||
self.song = song
|
||||
self.music_video = music_video
|
||||
self.uploaded_video = uploaded_video
|
||||
self.overwrite = overwrite
|
||||
self.save_cover = save_cover
|
||||
self.save_playlist = save_playlist
|
||||
self.no_synced_lyrics = no_synced_lyrics
|
||||
self.synced_lyrics_only = synced_lyrics_only
|
||||
self.skip_cleanup = skip_cleanup
|
||||
self.skip_processing = skip_processing
|
||||
self.flat_filter = flat_filter
|
||||
|
||||
async def get_single_download_item(
|
||||
self.base = song.base
|
||||
|
||||
async def get_download_item_from_url(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
url: str,
|
||||
) -> AsyncGenerator[DownloadItem, None]:
|
||||
async for media in self.base.interface.get_media_from_url(url):
|
||||
yield await self.parse_download_item(media)
|
||||
|
||||
async def parse_download_item(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> DownloadItem:
|
||||
if self.flat_filter:
|
||||
flat_filter_result = self.flat_filter(media_metadata)
|
||||
if asyncio.iscoroutine(flat_filter_result):
|
||||
flat_filter_result = await flat_filter_result
|
||||
if media.error:
|
||||
return DownloadItem(media)
|
||||
|
||||
if flat_filter_result:
|
||||
return DownloadItem(
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
flat_filter_result=flat_filter_result,
|
||||
)
|
||||
if media.partial:
|
||||
return DownloadItem(media)
|
||||
|
||||
return await self.get_single_download_item_no_filter(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
elif media.media_metadata["type"] in {"songs", "library-songs"}:
|
||||
return await self.song.get_download_item(media)
|
||||
|
||||
async def get_single_download_item_no_filter(
|
||||
self,
|
||||
media_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
try:
|
||||
if not self.base_downloader.is_media_streamable(
|
||||
media_metadata,
|
||||
):
|
||||
raise NotStreamable(media_metadata["id"])
|
||||
|
||||
if media_metadata["type"] in SONG_MEDIA_TYPE:
|
||||
if not self.song_downloader:
|
||||
raise UnsupportedMediaType(media_metadata["type"])
|
||||
|
||||
download_item = await self.song_downloader.get_download_item(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
|
||||
if media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
if not self.music_video_downloader:
|
||||
raise UnsupportedMediaType(media_metadata["type"])
|
||||
|
||||
download_item = await self.music_video_downloader.get_download_item(
|
||||
media_metadata,
|
||||
playlist_metadata,
|
||||
)
|
||||
|
||||
if media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
|
||||
if not self.uploaded_video_downloader:
|
||||
raise UnsupportedMediaType(media_metadata["type"])
|
||||
|
||||
download_item = await self.uploaded_video_downloader.get_download_item(
|
||||
media_metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
download_item = DownloadItem(
|
||||
media_metadata=media_metadata,
|
||||
playlist_metadata=playlist_metadata,
|
||||
error=e,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def get_collection_download_items(
|
||||
self,
|
||||
collection_metadata: dict,
|
||||
) -> list[DownloadItem]:
|
||||
tracks_metadata = collection_metadata["relationships"]["tracks"]["data"]
|
||||
async for extended_data in self.interface.apple_music_api.extend_api_data(
|
||||
collection_metadata["relationships"]["tracks"],
|
||||
):
|
||||
tracks_metadata.extend(extended_data["data"])
|
||||
|
||||
tasks = [
|
||||
self.get_single_download_item(
|
||||
media_metadata,
|
||||
(
|
||||
collection_metadata
|
||||
if collection_metadata["type"] in PLAYLIST_MEDIA_TYPE
|
||||
else None
|
||||
),
|
||||
)
|
||||
for media_metadata in tracks_metadata
|
||||
]
|
||||
|
||||
download_items = await safe_gather(*tasks)
|
||||
return download_items
|
||||
|
||||
async def get_artist_download_items(
|
||||
self,
|
||||
artist_metadata: dict,
|
||||
) -> list[DownloadItem]:
|
||||
media_type = await inquirer.select(
|
||||
message=f'Select which type to download for artist "{artist_metadata["attributes"]["name"]}":',
|
||||
choices=[
|
||||
Choice(
|
||||
name="Main Albums",
|
||||
value=["views", "full-albums"],
|
||||
),
|
||||
Choice(
|
||||
name="Compilations Albums",
|
||||
value=["views", "compilation-albums"],
|
||||
),
|
||||
Choice(
|
||||
name="Live Albums",
|
||||
value=["views", "live-albums"],
|
||||
),
|
||||
Choice(
|
||||
name="Singles & EPs",
|
||||
value=["views", "singles"],
|
||||
),
|
||||
Choice(
|
||||
name="All Albums",
|
||||
value=["relationships", "albums"],
|
||||
),
|
||||
Choice(
|
||||
name="Top Songs",
|
||||
value=["views", "top-songs"],
|
||||
),
|
||||
Choice(
|
||||
name="Music Videos",
|
||||
value=["relationships", "music-videos"],
|
||||
),
|
||||
],
|
||||
validate=lambda result: artist_metadata.get(result[0], {})
|
||||
.get(result[1], {})
|
||||
.get("data"),
|
||||
invalid_message="The artist doesn't have any items of this type",
|
||||
).execute_async()
|
||||
|
||||
media_type, media_type_key = media_type
|
||||
artist_metadata[media_type][media_type_key]["data"].extend(
|
||||
[
|
||||
extended_data
|
||||
async for extended_data in self.interface.apple_music_api.extend_api_data(
|
||||
artist_metadata[media_type][media_type_key],
|
||||
)
|
||||
]
|
||||
)
|
||||
selected_tracks = artist_metadata[media_type][media_type_key]["data"]
|
||||
|
||||
if media_type_key in {
|
||||
"full-albums",
|
||||
"compilation-albums",
|
||||
"live-albums",
|
||||
"singles",
|
||||
"albums",
|
||||
elif media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
}:
|
||||
return await self.get_artist_albums_download_items(selected_tracks)
|
||||
elif media_type_key == "top-songs":
|
||||
return await self.get_artist_songs_download_items(selected_tracks)
|
||||
elif media_type_key == "music-videos":
|
||||
return await self.get_artist_music_videos_download_items(selected_tracks)
|
||||
return await self.music_video.get_download_item(media)
|
||||
|
||||
async def get_artist_albums_download_items(
|
||||
self,
|
||||
albums_metadata: list[dict],
|
||||
) -> list[DownloadItem]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
f'{album["attributes"]["trackCount"]:03d}',
|
||||
f'{album["attributes"]["releaseDate"]:<10}',
|
||||
f'{album["attributes"].get("contentRating", "None").title():<8}',
|
||||
f'{album["attributes"]["name"]}',
|
||||
]
|
||||
),
|
||||
value=album,
|
||||
)
|
||||
for album in albums_metadata
|
||||
if album.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which albums to download: (Track Count | Release Date | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
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],
|
||||
) -> list[DownloadItem]:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
self.millis_to_min_sec(
|
||||
music_video["attributes"]["durationInMillis"]
|
||||
),
|
||||
f'{music_video["attributes"].get("contentRating", "None").title():<8}',
|
||||
music_video["attributes"]["name"],
|
||||
],
|
||||
),
|
||||
value=music_video,
|
||||
)
|
||||
for music_video in music_videos_metadata
|
||||
if music_video.get("attributes")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which music videos to download: (Duration | Rating | Title)",
|
||||
choices=choices,
|
||||
multiselect=True,
|
||||
).execute_async()
|
||||
|
||||
music_video_tasks = [
|
||||
self.get_single_download_item(
|
||||
music_video_metadata,
|
||||
)
|
||||
for music_video_metadata in selected
|
||||
]
|
||||
download_items = await safe_gather(*music_video_tasks)
|
||||
|
||||
return download_items
|
||||
|
||||
async def get_artist_songs_download_items(
|
||||
self,
|
||||
songs_metadata: list[dict],
|
||||
) -> list[DownloadItem]:
|
||||
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()
|
||||
|
||||
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:
|
||||
artist_response = await self.interface.apple_music_api.get_artist(
|
||||
id,
|
||||
)
|
||||
|
||||
if artist_response is None:
|
||||
return None
|
||||
|
||||
download_items = await self.get_artist_download_items(
|
||||
artist_response["data"][0],
|
||||
)
|
||||
|
||||
if url_type in SONG_MEDIA_TYPE:
|
||||
song_respose = await self.interface.apple_music_api.get_song(id)
|
||||
|
||||
if song_respose is None:
|
||||
return None
|
||||
|
||||
download_items.append(
|
||||
await self.get_single_download_item(song_respose["data"][0])
|
||||
)
|
||||
|
||||
if url_type in ALBUM_MEDIA_TYPE:
|
||||
if is_library:
|
||||
album_response = await self.interface.apple_music_api.get_library_album(
|
||||
id
|
||||
)
|
||||
else:
|
||||
album_response = await self.interface.apple_music_api.get_album(id)
|
||||
|
||||
if album_response is None:
|
||||
return None
|
||||
|
||||
download_items = await self.get_collection_download_items(
|
||||
album_response["data"][0],
|
||||
)
|
||||
|
||||
if url_type in PLAYLIST_MEDIA_TYPE:
|
||||
if is_library:
|
||||
playlist_response = (
|
||||
await self.interface.apple_music_api.get_library_playlist(id)
|
||||
)
|
||||
else:
|
||||
playlist_response = await self.interface.apple_music_api.get_playlist(
|
||||
id
|
||||
)
|
||||
|
||||
if playlist_response is None:
|
||||
return None
|
||||
|
||||
download_items = await self.get_collection_download_items(
|
||||
playlist_response["data"][0],
|
||||
)
|
||||
|
||||
if url_type in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
music_video_response = await self.interface.apple_music_api.get_music_video(
|
||||
id
|
||||
)
|
||||
|
||||
if music_video_response is None:
|
||||
return None
|
||||
|
||||
download_items.append(
|
||||
await self.get_single_download_item(music_video_response["data"][0])
|
||||
)
|
||||
|
||||
if url_type in UPLOADED_VIDEO_MEDIA_TYPE:
|
||||
uploaded_video = await self.interface.apple_music_api.get_uploaded_video(id)
|
||||
|
||||
if uploaded_video is None:
|
||||
return None
|
||||
|
||||
download_items.append(
|
||||
await self.get_single_download_item(uploaded_video["data"][0])
|
||||
)
|
||||
|
||||
return download_items
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> DownloadItem:
|
||||
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)
|
||||
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 download_item.media_metadata["type"] in {
|
||||
*SONG_MEDIA_TYPE,
|
||||
*MUSIC_VIDEO_MEDIA_TYPE,
|
||||
}:
|
||||
if (
|
||||
not self.base_downloader.use_wrapper
|
||||
or download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE
|
||||
or self.song_downloader.codec.is_legacy()
|
||||
):
|
||||
if (
|
||||
self.base_downloader.remux_mode == RemuxMode.FFMPEG
|
||||
and not self.base_downloader.full_ffmpeg_path
|
||||
):
|
||||
raise ExecutableNotFound("ffmpeg")
|
||||
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))
|
||||
)
|
||||
|
||||
if (
|
||||
self.base_downloader.remux_mode == RemuxMode.MP4BOX
|
||||
and not self.base_downloader.full_mp4box_path
|
||||
):
|
||||
raise ExecutableNotFound("MP4Box")
|
||||
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
|
||||
or self.base_downloader.remux_mode == RemuxMode.MP4BOX
|
||||
) and not self.base_downloader.full_mp4decrypt_path:
|
||||
raise ExecutableNotFound("mp4decrypt")
|
||||
log.debug("success")
|
||||
|
||||
if (
|
||||
self.base_downloader.download_mode == DownloadMode.NM3U8DLRE
|
||||
and not self.base_downloader.full_nm3u8dlre_path
|
||||
):
|
||||
raise ExecutableNotFound("N_m3u8DL-RE")
|
||||
def _write_cover(self, cover_path: str, cover_bytes: bytes) -> None:
|
||||
log = logger.bind(action="write_cover_file", cover_path=cover_path)
|
||||
|
||||
if (
|
||||
not download_item.stream_info
|
||||
or not download_item.stream_info.audio_track
|
||||
or not download_item.stream_info.audio_track.stream_url
|
||||
or (
|
||||
(
|
||||
not download_item.decryption_key
|
||||
or not download_item.decryption_key.audio_track
|
||||
or not download_item.decryption_key.audio_track.key
|
||||
)
|
||||
and not self.base_downloader.use_wrapper
|
||||
)
|
||||
):
|
||||
raise FormatNotAvailable(download_item.media_metadata["id"])
|
||||
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(cover_path, "wb") as f:
|
||||
f.write(cover_bytes)
|
||||
|
||||
if download_item.media_metadata["type"] in SONG_MEDIA_TYPE:
|
||||
await self.song_downloader.download(download_item)
|
||||
log.debug("success")
|
||||
|
||||
if download_item.media_metadata["type"] in MUSIC_VIDEO_MEDIA_TYPE:
|
||||
await self.music_video_downloader.download(download_item)
|
||||
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,
|
||||
)
|
||||
|
||||
if download_item.media_metadata["type"] in UPLOADED_VIDEO_MEDIA_TYPE:
|
||||
await self.uploaded_video_downloader.download(download_item)
|
||||
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)
|
||||
|
||||
async def _initial_processing(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
log.debug("success")
|
||||
|
||||
async def _initial_processing(self, item: DownloadItem) -> None:
|
||||
if self.skip_processing:
|
||||
return
|
||||
|
||||
if download_item.cover_path and self.base_downloader.save_cover:
|
||||
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
|
||||
if cover_bytes and (
|
||||
self.base_downloader.overwrite
|
||||
or not Path(download_item.cover_path).exists()
|
||||
):
|
||||
self.base_downloader.write_cover_image(
|
||||
if item.playlist_file_path and item.final_path and self.save_playlist:
|
||||
self._update_playlist_file(
|
||||
item.playlist_file_path,
|
||||
item.final_path,
|
||||
item.media.playlist_tags.track,
|
||||
)
|
||||
|
||||
if item.cover_path and self.save_cover and item.media.cover.url:
|
||||
cover_bytes = await self.base.interface.base.get_cover_bytes(
|
||||
item.media.cover.url,
|
||||
)
|
||||
if cover_bytes and (self.overwrite or not Path(item.cover_path).exists()):
|
||||
self._write_cover(
|
||||
item.cover_path,
|
||||
cover_bytes,
|
||||
download_item.cover_path,
|
||||
)
|
||||
|
||||
if (
|
||||
download_item.lyrics
|
||||
and download_item.lyrics.synced
|
||||
and not self.song_downloader.no_synced_lyrics
|
||||
and (
|
||||
self.base_downloader.overwrite
|
||||
or not Path(download_item.synced_lyrics_path).exists()
|
||||
)
|
||||
item.synced_lyrics_path
|
||||
and not self.no_synced_lyrics
|
||||
and item.media.lyrics
|
||||
and item.media.lyrics.synced
|
||||
and (self.overwrite or not Path(item.synced_lyrics_path).exists())
|
||||
):
|
||||
self.song_downloader.write_synced_lyrics(
|
||||
download_item.lyrics.synced,
|
||||
download_item.synced_lyrics_path,
|
||||
self._write_synced_lyrics(
|
||||
item.synced_lyrics_path,
|
||||
item.media.lyrics.synced,
|
||||
)
|
||||
|
||||
if download_item.playlist_tags and self.base_downloader.save_playlist:
|
||||
self.base_downloader.update_playlist_file(
|
||||
download_item.playlist_file_path,
|
||||
download_item.final_path,
|
||||
download_item.playlist_tags.playlist_track,
|
||||
)
|
||||
async def _download(self, item: DownloadItem) -> None:
|
||||
if item.media.error:
|
||||
raise item.media.error
|
||||
|
||||
if self.synced_lyrics_only:
|
||||
raise GamdlDownloaderSyncedLyricsOnlyError()
|
||||
|
||||
if Path(item.final_path).exists() and not self.overwrite:
|
||||
raise GamdlDownloaderMediaFileExistsError(item.final_path)
|
||||
|
||||
if item.media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
"songs",
|
||||
"library-songs",
|
||||
}:
|
||||
if (
|
||||
self.base.download_mode == DownloadMode.NM3U8DLRE
|
||||
and not self.base.full_nm3u8dlre_path
|
||||
):
|
||||
raise GamdlDownloaderDependencyNotFoundError("N_m3u8DL-RE")
|
||||
|
||||
if item.media.media_metadata["type"] in {"songs", "library-songs"}:
|
||||
await self.song.download(item)
|
||||
|
||||
elif item.media.media_metadata["type"] in {
|
||||
"music-videos",
|
||||
"library-music-videos",
|
||||
}:
|
||||
if not self.base.full_mp4decrypt_path:
|
||||
raise GamdlDownloaderDependencyNotFoundError("mp4decrypt")
|
||||
|
||||
if (
|
||||
self.music_video.remux_mode == RemuxMode.FFMPEG
|
||||
and not self.base.full_ffmpeg_path
|
||||
):
|
||||
raise GamdlDownloaderDependencyNotFoundError("FFmpeg")
|
||||
|
||||
if (
|
||||
self.music_video.remux_mode == RemuxMode.MP4BOX
|
||||
and not self.base.full_mp4box_path
|
||||
):
|
||||
raise GamdlDownloaderDependencyNotFoundError("MP4Box")
|
||||
|
||||
await self.music_video.download(item)
|
||||
|
||||
elif item.media.media_metadata["type"] in {"uploaded-videos"}:
|
||||
await self.uploaded_video.download(item)
|
||||
|
||||
def _move_to_final_path(self, staged_path: str, final_path: str) -> None:
|
||||
log = logger.bind(
|
||||
action="move_to_final_path",
|
||||
staged_path=staged_path,
|
||||
final_path=final_path,
|
||||
)
|
||||
|
||||
Path(final_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(staged_path, final_path)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
async def _final_processing(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
item: DownloadItem,
|
||||
) -> None:
|
||||
if self.skip_processing:
|
||||
return
|
||||
|
||||
if download_item.staged_path and Path(download_item.staged_path).exists():
|
||||
self.base_downloader.move_to_final_path(
|
||||
download_item.staged_path,
|
||||
download_item.final_path,
|
||||
if Path(item.staged_path).exists():
|
||||
self._move_to_final_path(
|
||||
item.staged_path,
|
||||
item.final_path,
|
||||
)
|
||||
|
||||
def _cleanup_temp(self, folder_tag: str) -> None:
|
||||
log = logger.bind(action="cleanup_temp", folder_tag=folder_tag)
|
||||
|
||||
temp_path = Path(self.base.temp_path) / TEMP_PATH_TEMPLATE.format(folder_tag)
|
||||
if temp_path.exists() and temp_path.is_dir() and not self.skip_cleanup:
|
||||
shutil.rmtree(temp_path, ignore_errors=True)
|
||||
log.debug("success")
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import MusicVideoCodec, MusicVideoResolution
|
||||
from ..interface.interface_music_video import AppleMusicMusicVideoInterface
|
||||
from ..interface.types import DecryptionKeyAv
|
||||
from ..utils import async_subprocess
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .enums import RemuxFormatMusicVideo, RemuxMode
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicMusicVideoDownloader(AppleMusicBaseDownloader):
|
||||
def __init__(
|
||||
self,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
interface: AppleMusicMusicVideoInterface,
|
||||
codec_priority: list[MusicVideoCodec] = [
|
||||
MusicVideoCodec.H264,
|
||||
MusicVideoCodec.H265,
|
||||
],
|
||||
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
|
||||
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
|
||||
):
|
||||
self.__dict__.update(base_downloader.__dict__)
|
||||
self.interface = interface
|
||||
self.codec_priority = codec_priority
|
||||
self.remux_format = remux_format
|
||||
self.resolution = resolution
|
||||
|
||||
async def remux_mp4box(
|
||||
self,
|
||||
input_path_video: str,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.full_mp4box_path,
|
||||
"-quiet",
|
||||
"-add",
|
||||
input_path_audio,
|
||||
"-add",
|
||||
input_path_video,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
output_path,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def remux_ffmpeg(
|
||||
self,
|
||||
input_path_video: str,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
decryption_key: str = None,
|
||||
):
|
||||
if decryption_key:
|
||||
key = [
|
||||
"-decryption_key",
|
||||
decryption_key,
|
||||
]
|
||||
else:
|
||||
key = []
|
||||
|
||||
await async_subprocess(
|
||||
self.full_ffmpeg_path,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
*key,
|
||||
"-i",
|
||||
input_path_video,
|
||||
"-i",
|
||||
input_path_audio,
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
"mov_text",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
output_path,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def decrypt_mp4decrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.full_mp4decrypt_path,
|
||||
"--key",
|
||||
f"1:{decryption_key}",
|
||||
input_path,
|
||||
output_path,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path_video: str,
|
||||
encrypted_path_audio: str,
|
||||
decrypted_path_video: str,
|
||||
decrypted_path_audio: str,
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
):
|
||||
await self.decrypt_mp4decrypt(
|
||||
encrypted_path_video,
|
||||
decrypted_path_video,
|
||||
decryption_key.video_track.key,
|
||||
)
|
||||
await self.decrypt_mp4decrypt(
|
||||
encrypted_path_audio,
|
||||
decrypted_path_audio,
|
||||
decryption_key.audio_track.key,
|
||||
)
|
||||
|
||||
if self.remux_mode == RemuxMode.MP4BOX:
|
||||
await self.remux_mp4box(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
staged_path,
|
||||
)
|
||||
else:
|
||||
await self.remux_ffmpeg(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
staged_path,
|
||||
)
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(Path(final_path).with_suffix(file_extension))
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = music_video_metadata
|
||||
download_item.playlist_metadata = playlist_metadata
|
||||
|
||||
music_video_id = self.interface.get_media_id_of_library_media(
|
||||
music_video_metadata,
|
||||
)
|
||||
|
||||
itunes_page_metadata = await self.interface.get_itunes_page_metadata(
|
||||
music_video_metadata,
|
||||
)
|
||||
download_item.media_tags = await self.interface.get_tags(
|
||||
music_video_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
if playlist_metadata:
|
||||
download_item.playlist_tags = self.get_playlist_tags(
|
||||
playlist_metadata,
|
||||
music_video_metadata,
|
||||
)
|
||||
download_item.playlist_file_path = self.get_playlist_file_path(
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.stream_info = await self.interface.get_stream_info(
|
||||
music_video_metadata,
|
||||
itunes_page_metadata,
|
||||
self.codec_priority,
|
||||
self.resolution,
|
||||
)
|
||||
|
||||
download_item.decryption_key = await self.interface.get_decryption_key(
|
||||
download_item.stream_info,
|
||||
self.cdm,
|
||||
)
|
||||
|
||||
download_item.random_uuid = self.get_random_uuid()
|
||||
download_item.staged_path = self.get_temp_path(
|
||||
music_video_id,
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
(
|
||||
"."
|
||||
+ (
|
||||
"mp4"
|
||||
if self.remux_format == RemuxFormatMusicVideo.MP4
|
||||
else download_item.stream_info.file_format.value
|
||||
)
|
||||
),
|
||||
)
|
||||
download_item.final_path = self.get_final_path(
|
||||
download_item.media_tags,
|
||||
Path(download_item.staged_path).suffix,
|
||||
playlist_metadata,
|
||||
)
|
||||
|
||||
download_item.cover_url_template = self.interface.get_cover_url_template(
|
||||
music_video_metadata,
|
||||
self.cover_format,
|
||||
)
|
||||
download_item.cover_url = self.interface.get_cover_url(
|
||||
download_item.cover_url_template,
|
||||
self.cover_size,
|
||||
self.cover_format,
|
||||
)
|
||||
|
||||
cover_file_extension = await self.interface.get_cover_file_extension(
|
||||
download_item.cover_url,
|
||||
self.cover_format,
|
||||
)
|
||||
if cover_file_extension:
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
cover_file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
encrypted_path_video = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
encrypted_path_audio = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.download_stream(
|
||||
download_item.stream_info.video_track.stream_url,
|
||||
encrypted_path_video,
|
||||
)
|
||||
await self.download_stream(
|
||||
download_item.stream_info.audio_track.stream_url,
|
||||
encrypted_path_audio,
|
||||
)
|
||||
|
||||
decrypted_path_video = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"decrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
decrypted_path_audio = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"decrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path_video,
|
||||
encrypted_path_audio,
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
download_item.staged_path,
|
||||
download_item.decryption_key,
|
||||
)
|
||||
|
||||
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
|
||||
await self.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -1,346 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import SongCodec, SyncedLyricsFormat
|
||||
from ..interface.interface_song import AppleMusicSongInterface
|
||||
from ..interface.types import DecryptionKeyAv
|
||||
from ..utils import async_subprocess
|
||||
from .amdecrypt import decrypt_file
|
||||
from .constants import DEFAULT_SONG_DECRYPTION_KEY
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .enums import RemuxMode
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicSongDownloader(AppleMusicBaseDownloader):
|
||||
def __init__(
|
||||
self,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
interface: AppleMusicSongInterface,
|
||||
codec: SongCodec = SongCodec.AAC_LEGACY,
|
||||
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
|
||||
no_synced_lyrics: bool = False,
|
||||
synced_lyrics_only: bool = False,
|
||||
use_album_date: bool = False,
|
||||
fetch_extra_tags: bool = False,
|
||||
):
|
||||
self.__dict__.update(base_downloader.__dict__)
|
||||
self.interface = interface
|
||||
self.codec = codec
|
||||
self.synced_lyrics_format = synced_lyrics_format
|
||||
self.no_synced_lyrics = no_synced_lyrics
|
||||
self.synced_lyrics_only = synced_lyrics_only
|
||||
self.use_album_date = use_album_date
|
||||
self.fetch_extra_tags = fetch_extra_tags
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
playlist_metadata: dict = None,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = song_metadata
|
||||
download_item.playlist_metadata = playlist_metadata
|
||||
|
||||
song_id = self.interface.get_media_id_of_library_media(song_metadata)
|
||||
|
||||
download_item.lyrics = await self.interface.get_lyrics(
|
||||
song_metadata,
|
||||
self.synced_lyrics_format,
|
||||
)
|
||||
|
||||
webplayback = await self.interface.apple_music_api.get_webplayback(song_id)
|
||||
download_item.media_tags = await self.interface.get_tags(
|
||||
webplayback,
|
||||
download_item.lyrics.unsynced if download_item.lyrics else None,
|
||||
self.use_album_date,
|
||||
)
|
||||
if self.fetch_extra_tags:
|
||||
download_item.extra_tags = await self.interface.get_extra_tags(
|
||||
song_metadata,
|
||||
)
|
||||
|
||||
if playlist_metadata:
|
||||
download_item.playlist_tags = self.get_playlist_tags(
|
||||
playlist_metadata,
|
||||
song_metadata,
|
||||
)
|
||||
download_item.playlist_file_path = self.get_playlist_file_path(
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.final_path = self.get_final_path(
|
||||
download_item.media_tags,
|
||||
".m4a",
|
||||
download_item.playlist_tags,
|
||||
)
|
||||
download_item.synced_lyrics_path = self.get_lyrics_synced_path(
|
||||
download_item.final_path,
|
||||
)
|
||||
|
||||
if self.synced_lyrics_only:
|
||||
return download_item
|
||||
|
||||
if self.codec.is_legacy():
|
||||
download_item.stream_info = await self.interface.get_stream_info_legacy(
|
||||
webplayback,
|
||||
self.codec,
|
||||
)
|
||||
download_item.decryption_key = (
|
||||
await self.interface.get_decryption_key_legacy(
|
||||
download_item.stream_info,
|
||||
self.cdm,
|
||||
)
|
||||
)
|
||||
else:
|
||||
download_item.stream_info = await self.interface.get_stream_info(
|
||||
song_metadata,
|
||||
self.codec,
|
||||
)
|
||||
if (
|
||||
not self.use_wrapper
|
||||
and download_item.stream_info
|
||||
and download_item.stream_info.audio_track.widevine_pssh
|
||||
):
|
||||
download_item.decryption_key = await self.interface.get_decryption_key(
|
||||
download_item.stream_info,
|
||||
self.cdm,
|
||||
)
|
||||
else:
|
||||
download_item.decryption_key = None
|
||||
|
||||
download_item.cover_url_template = self.interface.get_cover_url_template(
|
||||
song_metadata,
|
||||
self.cover_format,
|
||||
)
|
||||
download_item.cover_url = self.interface.get_cover_url(
|
||||
download_item.cover_url_template,
|
||||
self.cover_size,
|
||||
self.cover_format,
|
||||
)
|
||||
|
||||
download_item.random_uuid = self.get_random_uuid()
|
||||
if download_item.stream_info and download_item.stream_info.file_format:
|
||||
download_item.staged_path = self.get_temp_path(
|
||||
song_id,
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
"." + download_item.stream_info.file_format.value,
|
||||
)
|
||||
else:
|
||||
download_item.staged_path = None
|
||||
|
||||
cover_file_extension = await self.interface.get_cover_file_extension(
|
||||
download_item.cover_url,
|
||||
self.cover_format,
|
||||
)
|
||||
if cover_file_extension:
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
cover_file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
def fix_key_id(self, input_path: str):
|
||||
count = 0
|
||||
with open(input_path, "rb+") as file:
|
||||
while data := file.read(4096):
|
||||
pos = file.tell()
|
||||
i = 0
|
||||
while tenc := max(0, data.find(b"tenc", i)):
|
||||
kid = tenc + 12
|
||||
file.seek(max(0, pos - 4096) + kid, 0)
|
||||
file.write(bytes.fromhex(f"{count:032}"))
|
||||
count += 1
|
||||
i = kid + 1
|
||||
file.seek(pos, 0)
|
||||
|
||||
async def remux_mp4box(self, input_path: str, output_path: str):
|
||||
await async_subprocess(
|
||||
self.full_mp4box_path,
|
||||
"-quiet",
|
||||
"-add",
|
||||
input_path,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
output_path,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def remux_ffmpeg(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str = None,
|
||||
):
|
||||
if decryption_key:
|
||||
key = [
|
||||
"-decryption_key",
|
||||
decryption_key,
|
||||
]
|
||||
else:
|
||||
key = []
|
||||
|
||||
await async_subprocess(
|
||||
self.full_ffmpeg_path,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
*key,
|
||||
"-i",
|
||||
input_path,
|
||||
"-c",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
output_path,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def decrypt_mp4decrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str,
|
||||
legacy: bool,
|
||||
):
|
||||
if legacy:
|
||||
keys = [
|
||||
"--key",
|
||||
f"1:{decryption_key}",
|
||||
]
|
||||
else:
|
||||
self.fix_key_id(input_path)
|
||||
keys = [
|
||||
"--key",
|
||||
"0" * 31 + "1" + f":{decryption_key}",
|
||||
"--key",
|
||||
"0" * 32 + f":{DEFAULT_SONG_DECRYPTION_KEY}",
|
||||
]
|
||||
|
||||
await async_subprocess(
|
||||
self.full_mp4decrypt_path,
|
||||
*keys,
|
||||
input_path,
|
||||
output_path,
|
||||
silent=self.silent,
|
||||
)
|
||||
|
||||
async def decrypt_amdecrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
media_id: str,
|
||||
fairplay_key: str,
|
||||
) -> None:
|
||||
await decrypt_file(
|
||||
self.wrapper_decrypt_ip,
|
||||
media_id,
|
||||
fairplay_key,
|
||||
input_path,
|
||||
output_path,
|
||||
)
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path: str,
|
||||
decrypted_path: str,
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
codec: SongCodec,
|
||||
media_id: str,
|
||||
fairplay_key: str,
|
||||
):
|
||||
if codec.is_legacy() and self.remux_mode == RemuxMode.FFMPEG:
|
||||
await self.remux_ffmpeg(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
decryption_key.audio_track.key,
|
||||
)
|
||||
elif codec.is_legacy() or not self.use_wrapper:
|
||||
await self.decrypt_mp4decrypt(
|
||||
encrypted_path,
|
||||
decrypted_path,
|
||||
decryption_key.audio_track.key,
|
||||
codec.is_legacy(),
|
||||
)
|
||||
if self.remux_mode == RemuxMode.FFMPEG:
|
||||
await self.remux_ffmpeg(
|
||||
decrypted_path,
|
||||
staged_path,
|
||||
)
|
||||
else:
|
||||
await self.remux_mp4box(
|
||||
decrypted_path,
|
||||
staged_path,
|
||||
)
|
||||
else:
|
||||
await self.decrypt_amdecrypt(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
media_id,
|
||||
fairplay_key,
|
||||
)
|
||||
|
||||
def get_lyrics_synced_path(self, final_path: str) -> str:
|
||||
return str(Path(final_path).with_suffix("." + self.synced_lyrics_format.value))
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(Path(final_path).parent / ("Cover" + file_extension))
|
||||
|
||||
def write_synced_lyrics(
|
||||
self,
|
||||
synced_lyrics: str,
|
||||
lyrics_synced_path: str,
|
||||
):
|
||||
Path(lyrics_synced_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(lyrics_synced_path).write_text(synced_lyrics, encoding="utf8")
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
if self.synced_lyrics_only:
|
||||
return
|
||||
|
||||
encrypted_path = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"encrypted",
|
||||
".m4a",
|
||||
)
|
||||
await self.download_stream(
|
||||
download_item.stream_info.audio_track.stream_url,
|
||||
encrypted_path,
|
||||
)
|
||||
|
||||
decrypted_path = self.get_temp_path(
|
||||
download_item.media_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"decrypted",
|
||||
".m4a",
|
||||
)
|
||||
await self.stage(
|
||||
encrypted_path,
|
||||
decrypted_path,
|
||||
download_item.staged_path,
|
||||
download_item.decryption_key,
|
||||
self.codec,
|
||||
download_item.media_metadata["id"],
|
||||
download_item.stream_info.audio_track.fairplay_key,
|
||||
)
|
||||
|
||||
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
|
||||
await self.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
cover_bytes,
|
||||
download_item.extra_tags,
|
||||
)
|
||||
@@ -1,103 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import UploadedVideoQuality
|
||||
from ..interface.interface_uploaded_video import AppleMusicUploadedVideoInterface
|
||||
from .downloader_base import AppleMusicBaseDownloader
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoDownloader(AppleMusicBaseDownloader):
|
||||
def __init__(
|
||||
self,
|
||||
base_downloader: AppleMusicBaseDownloader,
|
||||
interface: AppleMusicUploadedVideoInterface,
|
||||
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
|
||||
):
|
||||
self.__dict__.update(base_downloader.__dict__)
|
||||
self.interface = interface
|
||||
self.quality = quality
|
||||
|
||||
def get_cover_path(self, final_path: str, file_extension: str) -> str:
|
||||
return str(Path(final_path).with_suffix(file_extension))
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
uploaded_video_metadata: dict,
|
||||
) -> DownloadItem:
|
||||
try:
|
||||
return await self._get_download_item(
|
||||
uploaded_video_metadata,
|
||||
)
|
||||
except Exception as e:
|
||||
return DownloadItem(
|
||||
media_metadata=uploaded_video_metadata,
|
||||
error=e,
|
||||
)
|
||||
|
||||
async def _get_download_item(
|
||||
self,
|
||||
uploaded_video_metadata: dict,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem()
|
||||
|
||||
download_item.media_metadata = uploaded_video_metadata
|
||||
|
||||
download_item.media_tags = self.interface.get_tags(
|
||||
uploaded_video_metadata,
|
||||
)
|
||||
|
||||
download_item.stream_info = await self.interface.get_stream_info(
|
||||
uploaded_video_metadata,
|
||||
self.quality,
|
||||
)
|
||||
|
||||
download_item.random_uuid = self.get_random_uuid()
|
||||
download_item.staged_path = self.get_temp_path(
|
||||
uploaded_video_metadata["id"],
|
||||
download_item.random_uuid,
|
||||
"staged",
|
||||
"." + download_item.stream_info.file_format.value,
|
||||
)
|
||||
download_item.final_path = self.get_final_path(
|
||||
download_item.media_tags,
|
||||
Path(download_item.staged_path).suffix,
|
||||
None,
|
||||
)
|
||||
|
||||
download_item.cover_url_template = self.interface.get_cover_url_template(
|
||||
uploaded_video_metadata,
|
||||
self.cover_format,
|
||||
)
|
||||
download_item.cover_url = self.interface.get_cover_url(
|
||||
download_item.cover_url_template,
|
||||
self.cover_size,
|
||||
self.cover_format,
|
||||
)
|
||||
|
||||
cover_file_extension = await self.interface.get_cover_file_extension(
|
||||
download_item.cover_url,
|
||||
self.cover_format,
|
||||
)
|
||||
if cover_file_extension:
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
cover_file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
await self.download_ytdlp(
|
||||
download_item.stream_info.video_track.stream_url,
|
||||
download_item.staged_path,
|
||||
)
|
||||
|
||||
cover_bytes = await self.interface.get_cover_bytes(download_item.cover_url)
|
||||
await self.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media_tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -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,213 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
|
||||
from ..utils import async_subprocess
|
||||
from .base import AppleMusicBaseDownloader
|
||||
from .enums import RemuxFormatMusicVideo, RemuxMode
|
||||
from .types import DownloadItem
|
||||
|
||||
|
||||
class AppleMusicMusicVideoDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseDownloader,
|
||||
remux_mode: RemuxMode = RemuxMode.FFMPEG,
|
||||
remux_format: RemuxFormatMusicVideo = RemuxFormatMusicVideo.M4V,
|
||||
):
|
||||
self.base = base
|
||||
self.remux_mode = remux_mode
|
||||
self.remux_format = remux_format
|
||||
|
||||
async def _remux_mp4box(
|
||||
self,
|
||||
input_path_video: str,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.base.full_mp4box_path,
|
||||
"-quiet",
|
||||
"-add",
|
||||
input_path_audio,
|
||||
"-add",
|
||||
input_path_video,
|
||||
"-itags",
|
||||
"artist=placeholder",
|
||||
"-keep-utc",
|
||||
"-new",
|
||||
output_path,
|
||||
silent=self.base.silent,
|
||||
)
|
||||
|
||||
async def _remux_ffmpeg(
|
||||
self,
|
||||
input_path_video: str,
|
||||
input_path_audio: str,
|
||||
output_path: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.base.full_ffmpeg_path,
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
input_path_video,
|
||||
"-i",
|
||||
input_path_audio,
|
||||
"-c",
|
||||
"copy",
|
||||
"-c:s",
|
||||
"mov_text",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
output_path,
|
||||
silent=self.base.silent,
|
||||
)
|
||||
|
||||
async def _decrypt_mp4decrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str,
|
||||
):
|
||||
await async_subprocess(
|
||||
self.base.full_mp4decrypt_path,
|
||||
"--key",
|
||||
f"1:{decryption_key}",
|
||||
input_path,
|
||||
output_path,
|
||||
silent=self.base.silent,
|
||||
)
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path_video: str,
|
||||
encrypted_path_audio: str,
|
||||
decrypted_path_video: str,
|
||||
decrypted_path_audio: str,
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
):
|
||||
await self._decrypt_mp4decrypt(
|
||||
encrypted_path_video,
|
||||
decrypted_path_video,
|
||||
decryption_key.video_track.key,
|
||||
)
|
||||
await self._decrypt_mp4decrypt(
|
||||
encrypted_path_audio,
|
||||
decrypted_path_audio,
|
||||
decryption_key.audio_track.key,
|
||||
)
|
||||
|
||||
if self.remux_mode == RemuxMode.MP4BOX:
|
||||
await self._remux_mp4box(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
staged_path,
|
||||
)
|
||||
else:
|
||||
await self._remux_ffmpeg(
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
staged_path,
|
||||
)
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
return str(Path(final_path).with_suffix(file_extension))
|
||||
|
||||
async def get_download_item(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> DownloadItem:
|
||||
download_item = DownloadItem(media)
|
||||
|
||||
download_item.staged_path = self.base.get_temp_path(
|
||||
media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"staged",
|
||||
"." + media.stream_info.file_format.value,
|
||||
)
|
||||
|
||||
download_item.final_path = self.base.get_final_path(
|
||||
media.tags,
|
||||
"." + media.stream_info.file_format.value,
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
if media.playlist_tags:
|
||||
download_item.playlist_file_path = self.base.get_playlist_file_path(
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
media.cover.file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
encrypted_path_video = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"encrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
encrypted_path_audio = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"encrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.video_track.stream_url,
|
||||
encrypted_path_video,
|
||||
)
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.audio_track.stream_url,
|
||||
encrypted_path_audio,
|
||||
)
|
||||
|
||||
decrypted_path_video = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"decrypted_video",
|
||||
".mp4",
|
||||
)
|
||||
decrypted_path_audio = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"decrypted_audio",
|
||||
".m4a",
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path_video,
|
||||
encrypted_path_audio,
|
||||
decrypted_path_video,
|
||||
decrypted_path_audio,
|
||||
download_item.staged_path,
|
||||
download_item.media.decryption_key,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.base.interface.base.get_cover_bytes(
|
||||
download_item.media.cover.url
|
||||
)
|
||||
if self.base.interface.base.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.base.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media.tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
from ..interface.enums import CoverFormat
|
||||
from ..interface.types import AppleMusicMedia, DecryptionKeyAv
|
||||
from .amdecrypt import decrypt_file, decrypt_file_hex
|
||||
from .base import AppleMusicBaseDownloader
|
||||
from .types import DownloadItem
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicSongDownloader:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseDownloader,
|
||||
):
|
||||
self.base = base
|
||||
|
||||
async def get_download_item(self, media: AppleMusicMedia) -> DownloadItem:
|
||||
download_item = DownloadItem(media)
|
||||
|
||||
if media.stream_info:
|
||||
download_item.staged_path = self.base.get_temp_path(
|
||||
media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"staged",
|
||||
"." + media.stream_info.file_format.value,
|
||||
)
|
||||
|
||||
download_item.final_path = self.base.get_final_path(
|
||||
media.tags,
|
||||
".m4a",
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
if media.playlist_tags:
|
||||
download_item.playlist_file_path = self.base.get_playlist_file_path(
|
||||
media.playlist_tags,
|
||||
)
|
||||
|
||||
download_item.synced_lyrics_path = self.get_synced_lyrics_path(
|
||||
download_item.final_path
|
||||
)
|
||||
|
||||
download_item.cover_path = self.get_cover_path(
|
||||
download_item.final_path,
|
||||
media.cover.file_extension,
|
||||
)
|
||||
|
||||
return download_item
|
||||
|
||||
async def _decrypt_amdecrypt(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
media_id: str,
|
||||
fairplay_key: str,
|
||||
) -> None:
|
||||
await decrypt_file(
|
||||
self.base.wrapper_decrypt_ip,
|
||||
media_id,
|
||||
fairplay_key,
|
||||
input_path,
|
||||
output_path,
|
||||
)
|
||||
|
||||
async def _decrypt_amdecrypt_hex(
|
||||
self,
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
decryption_key: str,
|
||||
legacy: bool = False,
|
||||
) -> None:
|
||||
await decrypt_file_hex(
|
||||
input_path,
|
||||
output_path,
|
||||
decryption_key,
|
||||
legacy=legacy,
|
||||
)
|
||||
|
||||
async def stage(
|
||||
self,
|
||||
encrypted_path: str,
|
||||
staged_path: str,
|
||||
decryption_key: DecryptionKeyAv,
|
||||
legacy: bool,
|
||||
media_id: str,
|
||||
fairplay_key: str,
|
||||
):
|
||||
log = logger.bind(
|
||||
action="stage_song",
|
||||
media_id=media_id,
|
||||
encrypted_path=encrypted_path,
|
||||
staged_path=staged_path,
|
||||
)
|
||||
|
||||
if self.base.interface.base.use_wrapper and not legacy:
|
||||
await self._decrypt_amdecrypt(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
media_id,
|
||||
fairplay_key,
|
||||
)
|
||||
else:
|
||||
await self._decrypt_amdecrypt_hex(
|
||||
encrypted_path,
|
||||
staged_path,
|
||||
decryption_key.audio_track.key,
|
||||
legacy,
|
||||
)
|
||||
|
||||
log.debug("success")
|
||||
|
||||
def get_synced_lyrics_path(self, final_path: str) -> str:
|
||||
log = logger.bind(action="get_synced_lyrics_path", final_path=final_path)
|
||||
|
||||
synced_lyrics_path = str(
|
||||
Path(final_path).with_suffix(
|
||||
"." + self.base.interface.song.synced_lyrics_format.value
|
||||
)
|
||||
)
|
||||
|
||||
log.debug("success", synced_lyrics_path=synced_lyrics_path)
|
||||
|
||||
return synced_lyrics_path
|
||||
|
||||
def get_cover_path(
|
||||
self,
|
||||
final_path: str,
|
||||
file_extension: str,
|
||||
) -> str:
|
||||
log = logger.bind(
|
||||
action="get_song_cover_path",
|
||||
final_path=final_path,
|
||||
file_extension=file_extension,
|
||||
)
|
||||
|
||||
cover_path = str(Path(final_path).parent / ("Cover" + file_extension))
|
||||
|
||||
log.debug("success", cover_path=cover_path)
|
||||
|
||||
return cover_path
|
||||
|
||||
async def download(
|
||||
self,
|
||||
download_item: DownloadItem,
|
||||
) -> None:
|
||||
encrypted_path = self.base.get_temp_path(
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.uuid_,
|
||||
"encrypted",
|
||||
".m4a",
|
||||
)
|
||||
await self.base.download_stream(
|
||||
download_item.media.stream_info.audio_track.stream_url,
|
||||
encrypted_path,
|
||||
)
|
||||
|
||||
await self.stage(
|
||||
encrypted_path,
|
||||
download_item.staged_path,
|
||||
download_item.media.decryption_key,
|
||||
download_item.media.stream_info.audio_track.legacy,
|
||||
download_item.media.media_metadata["id"],
|
||||
download_item.media.stream_info.audio_track.fairplay_key,
|
||||
)
|
||||
|
||||
cover_bytes = (
|
||||
await self.base.interface.base.get_cover_bytes(
|
||||
download_item.media.cover.url
|
||||
)
|
||||
if self.base.interface.base.cover_format != CoverFormat.RAW
|
||||
else None
|
||||
)
|
||||
await self.base.apply_tags(
|
||||
download_item.staged_path,
|
||||
download_item.media.tags,
|
||||
cover_bytes,
|
||||
)
|
||||
@@ -1,44 +1,15 @@
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ..interface.types import (
|
||||
DecryptionKeyAv,
|
||||
Lyrics,
|
||||
MediaTags,
|
||||
PlaylistTags,
|
||||
StreamInfoAv,
|
||||
)
|
||||
from ..interface.types import AppleMusicMedia
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadItem:
|
||||
media_metadata: dict = None
|
||||
playlist_metadata: dict = None
|
||||
random_uuid: str = None
|
||||
lyrics: Lyrics = None
|
||||
media_tags: MediaTags = None
|
||||
extra_tags: dict = None
|
||||
playlist_tags: PlaylistTags = None
|
||||
stream_info: StreamInfoAv = None
|
||||
decryption_key: DecryptionKeyAv = None
|
||||
cover_url_template: str = None
|
||||
cover_url: str = None
|
||||
media: AppleMusicMedia
|
||||
uuid_: str = uuid.uuid4().hex[:8]
|
||||
staged_path: str = None
|
||||
final_path: str = None
|
||||
playlist_file_path: str = None
|
||||
synced_lyrics_path: str = None
|
||||
cover_path: str = None
|
||||
flat_filter_result: Any = None
|
||||
error: Exception = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UrlInfo:
|
||||
storefront: str = None
|
||||
type: str = None
|
||||
slug: str = None
|
||||
id: str = None
|
||||
sub_id: str = None
|
||||
library_storefront: str = None
|
||||
library_type: str = None
|
||||
library_id: str = None
|
||||
|
||||
@@ -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,329 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import re
|
||||
from io import BytesIO
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
from async_lru import alru_cache
|
||||
from PIL import Image
|
||||
from pywidevine import PSSH, Cdm, Device
|
||||
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||
|
||||
from gamdl.interface.wvd import WVD
|
||||
|
||||
from ..api.apple_music import AppleMusicApi
|
||||
from ..api.itunes import ItunesApi
|
||||
from .constants import IMAGE_FILE_EXTENSION_MAP
|
||||
from .enums import CoverFormat
|
||||
from .types import Cover, DecryptionKey, PlaylistTags
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicBaseInterface:
|
||||
def __init__(
|
||||
self,
|
||||
apple_music_api: AppleMusicApi,
|
||||
itunes_api: ItunesApi,
|
||||
cover_format: CoverFormat,
|
||||
cover_size: int,
|
||||
use_wrapper: bool,
|
||||
wrapper_m3u8_ip: str,
|
||||
cdm: Cdm,
|
||||
) -> None:
|
||||
self.apple_music_api = apple_music_api
|
||||
self.itunes_api = itunes_api
|
||||
self.cover_format = cover_format
|
||||
self.cover_size = cover_size
|
||||
self.use_wrapper = use_wrapper
|
||||
self.wrapper_m3u8_ip = wrapper_m3u8_ip
|
||||
self.cdm = cdm
|
||||
|
||||
@staticmethod
|
||||
def create_cdm(wvd_path: str | None = None) -> Cdm:
|
||||
if wvd_path:
|
||||
cdm = Cdm.from_device(Device.load(wvd_path))
|
||||
else:
|
||||
cdm = Cdm.from_device(Device.loads(WVD))
|
||||
cdm.MAX_NUM_OF_SESSIONS = float("inf")
|
||||
|
||||
return cdm
|
||||
|
||||
@staticmethod
|
||||
def is_media_streamable(
|
||||
media_metadata: dict,
|
||||
) -> bool:
|
||||
return bool(media_metadata["attributes"].get("playParams"))
|
||||
|
||||
@staticmethod
|
||||
def parse_catalog_media_id(media_metadata: dict) -> str:
|
||||
play_params = media_metadata["attributes"].get("playParams", {})
|
||||
return play_params.get("catalogId", media_metadata["id"])
|
||||
|
||||
@staticmethod
|
||||
def parse_media_id_from_url(media_metadata: dict) -> str | None:
|
||||
media_url = media_metadata["attributes"].get("url")
|
||||
if media_url is None:
|
||||
return None
|
||||
|
||||
url_media_id = media_url.split("/")[-1].split("?")[0]
|
||||
|
||||
return url_media_id
|
||||
|
||||
@staticmethod
|
||||
def parse_date(date: str) -> datetime.datetime:
|
||||
return datetime.datetime.fromisoformat(date.split("Z")[0])
|
||||
|
||||
@staticmethod
|
||||
def reconstruct_pssh(pssh: str) -> bytes:
|
||||
pssh = pssh.split(",")[-1]
|
||||
|
||||
decoded_pssh = base64.b64decode(pssh)
|
||||
if len(decoded_pssh) > 30:
|
||||
return pssh
|
||||
|
||||
widevine_pssh_data = WidevinePsshData(
|
||||
algorithm=1,
|
||||
key_ids=[decoded_pssh],
|
||||
)
|
||||
|
||||
return widevine_pssh_data.SerializeToString()
|
||||
|
||||
@staticmethod
|
||||
async def get_response(
|
||||
url: str,
|
||||
valid_responses: list[int] = [200],
|
||||
) -> httpx.Response:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
try:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code in valid_responses:
|
||||
return e.response
|
||||
raise e
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def format_cover(
|
||||
template_cover_url: str,
|
||||
cover_size: int,
|
||||
cover_format: CoverFormat,
|
||||
) -> str:
|
||||
return re.sub(
|
||||
r"/\{w\}x\{h\}([a-z]{2})\.jpg",
|
||||
f"/{cover_size}x{cover_size}bb.{cover_format.value}",
|
||||
template_cover_url,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def create(
|
||||
cls,
|
||||
apple_music_api: AppleMusicApi,
|
||||
cover_format: CoverFormat = CoverFormat.JPG,
|
||||
cover_size: int = 1200,
|
||||
use_wrapper: bool = False,
|
||||
wrapper_m3u8_ip: str = "127.0.0.1:20020",
|
||||
wvd_path: str | None = None,
|
||||
itunes_api: ItunesApi | None = None,
|
||||
):
|
||||
itunes_api = itunes_api or await ItunesApi.create(
|
||||
storefront=apple_music_api.storefront,
|
||||
language=apple_music_api.language,
|
||||
)
|
||||
cdm = cls.create_cdm(wvd_path)
|
||||
|
||||
base = cls(
|
||||
apple_music_api=apple_music_api,
|
||||
itunes_api=itunes_api,
|
||||
cover_format=cover_format,
|
||||
cover_size=cover_size,
|
||||
use_wrapper=use_wrapper,
|
||||
wrapper_m3u8_ip=wrapper_m3u8_ip,
|
||||
cdm=cdm,
|
||||
)
|
||||
return base
|
||||
|
||||
@alru_cache()
|
||||
async def get_album_cached(
|
||||
self,
|
||||
album_id: int,
|
||||
) -> dict | None:
|
||||
return (await self.apple_music_api.get_album(album_id))["data"][0]
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
pssh: str,
|
||||
track_id: str,
|
||||
) -> DecryptionKey:
|
||||
log = logger.bind(action="get_decryption_key", track_id=track_id)
|
||||
|
||||
reconstructed_pssh = self.reconstruct_pssh(pssh)
|
||||
cdm_session = self.cdm.open()
|
||||
|
||||
try:
|
||||
pssh_obj = PSSH(reconstructed_pssh)
|
||||
|
||||
challenge = base64.b64encode(
|
||||
await asyncio.to_thread(
|
||||
self.cdm.get_license_challenge, cdm_session, pssh_obj
|
||||
)
|
||||
).decode()
|
||||
license = await self.apple_music_api.get_license_exchange(
|
||||
track_id,
|
||||
pssh,
|
||||
challenge,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.cdm.parse_license, cdm_session, license["license"]
|
||||
)
|
||||
decryption_key_info = next(
|
||||
i for i in self.cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
)
|
||||
finally:
|
||||
self.cdm.close(cdm_session)
|
||||
|
||||
decryption_key = DecryptionKey(
|
||||
key=decryption_key_info.key.hex(),
|
||||
kid=decryption_key_info.kid.hex,
|
||||
)
|
||||
|
||||
log.debug("success", decryption_key=decryption_key)
|
||||
|
||||
return decryption_key
|
||||
|
||||
@alru_cache()
|
||||
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
|
||||
log = logger.bind(action="get_cover_bytes", cover_url=cover_url)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(cover_url)
|
||||
|
||||
if response.status_code == 404:
|
||||
log.debug("cover_not_found")
|
||||
return None
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
return response.content
|
||||
|
||||
def _get_cover_template_url(self, metadata: dict) -> str:
|
||||
if self.cover_format == CoverFormat.RAW:
|
||||
cover_template_url = self._get_raw_cover_url(
|
||||
metadata["attributes"]["artwork"]["url"]
|
||||
)
|
||||
else:
|
||||
cover_template_url = metadata["attributes"]["artwork"]["url"]
|
||||
|
||||
return cover_template_url
|
||||
|
||||
def _get_raw_cover_url(self, cover_url_template: str) -> str:
|
||||
return re.sub(
|
||||
r"image/thumb/",
|
||||
"",
|
||||
re.sub(
|
||||
r"is1-ssl",
|
||||
"a1",
|
||||
cover_url_template,
|
||||
),
|
||||
)
|
||||
|
||||
@alru_cache()
|
||||
async def _get_cover_file_extension(
|
||||
self,
|
||||
cover_url: str,
|
||||
) -> str | None:
|
||||
log = logger.bind(action="get_cover_file_extension", cover_url=cover_url)
|
||||
if self.cover_format != CoverFormat.RAW:
|
||||
return f".{self.cover_format.value}"
|
||||
|
||||
cover_bytes = await self.get_cover_bytes(cover_url)
|
||||
if cover_bytes is None:
|
||||
log.debug("cover_bytes_empty")
|
||||
return None
|
||||
|
||||
image_obj = Image.open(BytesIO(cover_bytes))
|
||||
image_format = image_obj.format.lower()
|
||||
return IMAGE_FILE_EXTENSION_MAP.get(
|
||||
image_format,
|
||||
f".{image_format.lower()}",
|
||||
)
|
||||
|
||||
async def get_cover(
|
||||
self,
|
||||
metadata: dict,
|
||||
) -> str:
|
||||
log = logger.bind(
|
||||
action="get_cover", media_id=self.parse_catalog_media_id(metadata)
|
||||
)
|
||||
|
||||
template_url = self._get_cover_template_url(metadata)
|
||||
|
||||
if self.cover_format == CoverFormat.RAW:
|
||||
cover_url = template_url
|
||||
else:
|
||||
cover_url = self.format_cover(
|
||||
template_url,
|
||||
self.cover_size,
|
||||
self.cover_format,
|
||||
)
|
||||
|
||||
cover_file_extension = await self._get_cover_file_extension(cover_url)
|
||||
|
||||
cover = Cover(
|
||||
template_url=template_url,
|
||||
url=cover_url,
|
||||
file_extension=cover_file_extension,
|
||||
)
|
||||
|
||||
log.debug("success", cover=cover)
|
||||
|
||||
return cover
|
||||
|
||||
@alru_cache()
|
||||
async def get_media_date(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> datetime.datetime | None:
|
||||
log = logger.bind(action="get_media_date", media_id=media_id)
|
||||
|
||||
lookup_result = await self.itunes_api.get_lookup_result(media_id)
|
||||
if not lookup_result["results"]:
|
||||
log.debug("no_media_id")
|
||||
return None
|
||||
|
||||
release_date = lookup_result["results"][0].get("releaseDate")
|
||||
if not release_date:
|
||||
log.debug("no_release_date")
|
||||
return None
|
||||
|
||||
parsed_date = self.parse_date(release_date)
|
||||
|
||||
log.debug("success", release_date=parsed_date)
|
||||
|
||||
return parsed_date
|
||||
|
||||
def get_playlist_tags(
|
||||
self,
|
||||
playlist_metadata: dict,
|
||||
playlist_track: int,
|
||||
) -> PlaylistTags:
|
||||
log = logger.bind(
|
||||
action="get_playlist_tags",
|
||||
playlist_id=playlist_metadata["id"],
|
||||
)
|
||||
|
||||
playlist_tags = PlaylistTags(
|
||||
artist=playlist_metadata["attributes"].get("curatorName", "Unknown"),
|
||||
playlist_id=playlist_metadata["attributes"]["playParams"]["id"],
|
||||
title=playlist_metadata["attributes"]["name"],
|
||||
track=playlist_track,
|
||||
)
|
||||
|
||||
log.debug("success", playlist_tags=playlist_tags)
|
||||
|
||||
return playlist_tags
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
MEDIA_TYPE_STR_MAP = {
|
||||
1: "Song",
|
||||
6: "Music Video",
|
||||
@@ -60,3 +62,37 @@ IMAGE_FILE_EXTENSION_MAP = {
|
||||
"jpeg": ".jpg",
|
||||
"tiff": ".tif",
|
||||
}
|
||||
|
||||
VALID_URL_PATTERN = re.compile(
|
||||
r"https://(?:classical\.)?music\.apple\.com"
|
||||
r"(?:"
|
||||
r"/(?P<storefront>[a-z]{2})"
|
||||
r"/(?P<type>artist|album|playlist|song|music-video|post)"
|
||||
r"(?:/(?P<slug>[^\s/]+))?"
|
||||
r"/(?P<id>[0-9]+|pl\.[0-9a-z]{32}|pl\.u-[a-zA-Z0-9]+)"
|
||||
r"(?:\?i=(?P<sub_id>[0-9]+))?"
|
||||
r"|"
|
||||
r"(?:/(?P<library_storefront>[a-z]{2}))?"
|
||||
r"/library/(?P<library_type>playlist|albums)"
|
||||
r"/(?P<library_id>p\.[a-zA-Z0-9]+|l\.[a-zA-Z0-9]+)"
|
||||
r")"
|
||||
)
|
||||
|
||||
ARTIST_AUTO_SELECT_KEY_MAP = {
|
||||
"main-albums": ("views", "full-albums"),
|
||||
"compilation-albums": ("views", "compilation-albums"),
|
||||
"live-albums": ("views", "live-albums"),
|
||||
"singles-eps": ("views", "singles"),
|
||||
"all-albums": ("relationships", "albums"),
|
||||
"top-songs": ("views", "top-songs"),
|
||||
"music-videos": ("relationships", "music-videos"),
|
||||
}
|
||||
ARTIST_AUTO_SELECT_STR_MAP = {
|
||||
"main-albums": "Main Albums",
|
||||
"compilation-albums": "Compilation Albums",
|
||||
"live-albums": "Live Albums",
|
||||
"singles-eps": "Singles & EPs",
|
||||
"all-albums": "All Albums",
|
||||
"top-songs": "Top Songs",
|
||||
"music-videos": "Music Videos",
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from enum import Enum
|
||||
|
||||
from .constants import (
|
||||
ARTIST_AUTO_SELECT_KEY_MAP,
|
||||
ARTIST_AUTO_SELECT_STR_MAP,
|
||||
FOURCC_MAP,
|
||||
LEGACY_SONG_CODECS,
|
||||
MEDIA_RATING_STR_MAP,
|
||||
@@ -93,3 +95,20 @@ class CoverFormat(Enum):
|
||||
JPG = "jpg"
|
||||
PNG = "png"
|
||||
RAW = "raw"
|
||||
|
||||
|
||||
class ArtistMediaType(Enum):
|
||||
MAIN_ALBUMS = "main-albums"
|
||||
COMPILATION_ALBUMS = "compilation-albums"
|
||||
LIVE_ALBUMS = "live-albums"
|
||||
SINGLES_EPS = "singles-eps"
|
||||
ALL_ALBUMS = "all-albums"
|
||||
TOP_SONGS = "top-songs"
|
||||
MUSIC_VIDEOS = "music-videos"
|
||||
|
||||
@property
|
||||
def path_key(self) -> tuple[str, str]:
|
||||
return ARTIST_AUTO_SELECT_KEY_MAP[self.value]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return ARTIST_AUTO_SELECT_STR_MAP[self.value]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from ..utils import GamdlError
|
||||
from typing import Any
|
||||
|
||||
|
||||
class GamdlInterfaceError(GamdlError):
|
||||
pass
|
||||
|
||||
|
||||
class GamdlInterfaceMediaNotStreamableError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Media is not streamable: {media_id}")
|
||||
|
||||
|
||||
class GamdlInterfaceFormatNotAvailableError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str, codec: Any | None = None):
|
||||
super().__init__(
|
||||
f"Requested format is not available (media ID: {media_id}): {codec}"
|
||||
)
|
||||
|
||||
|
||||
class GamdlInterfaceDecryptionNotAvailableError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str):
|
||||
super().__init__(f"Decryption is not available for media ID: {media_id}")
|
||||
|
||||
|
||||
class GamdlInterfaceMediaNotAllowedError(GamdlInterfaceError):
|
||||
def __init__(self, media_type: str, media_id: str | None = None):
|
||||
message = "Media type is disallowed"
|
||||
if media_id:
|
||||
message += f" (media ID: {media_id})"
|
||||
|
||||
super().__init__(f"{message}: {media_type}")
|
||||
|
||||
|
||||
class GamdlInterfaceUrlParseError(GamdlInterfaceError):
|
||||
def __init__(self, url: str):
|
||||
super().__init__(f"URL is not valid or supported: {url}")
|
||||
|
||||
|
||||
class GamdlInterfaceArtistMediaTypeError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str, media_type: str):
|
||||
super().__init__(
|
||||
f"Artist has no media of type (media ID: {media_id}): {media_type}"
|
||||
)
|
||||
|
||||
|
||||
class GamdlInterfaceFlatFilterExcludedError(GamdlInterfaceError):
|
||||
def __init__(self, media_id: str, result: Any):
|
||||
super().__init__(f"Media excluded by flat filter: {media_id}")
|
||||
|
||||
self.result = result
|
||||
+446
-137
@@ -1,161 +1,470 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
from io import BytesIO
|
||||
from typing import Any, AsyncGenerator, Callable
|
||||
|
||||
from async_lru import alru_cache
|
||||
from PIL import Image
|
||||
from pywidevine import PSSH, Cdm
|
||||
import structlog
|
||||
|
||||
from ..api.apple_music_api import AppleMusicApi
|
||||
from ..api.itunes_api import ItunesApi
|
||||
from ..utils import get_response
|
||||
from .constants import IMAGE_FILE_EXTENSION_MAP
|
||||
from .enums import CoverFormat
|
||||
from .types import DecryptionKey
|
||||
from ..utils import safe_gather
|
||||
from .constants import VALID_URL_PATTERN
|
||||
from .enums import ArtistMediaType
|
||||
from .exceptions import (
|
||||
GamdlInterfaceMediaNotAllowedError,
|
||||
GamdlInterfaceUrlParseError,
|
||||
GamdlInterfaceArtistMediaTypeError,
|
||||
GamdlInterfaceFlatFilterExcludedError,
|
||||
)
|
||||
from .music_video import AppleMusicMusicVideoInterface
|
||||
from .song import AppleMusicSongInterface
|
||||
from .types import AppleMusicMedia, AppleMusicUrlInfo
|
||||
from .uploaded_video import AppleMusicUploadedVideoInterface
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicInterface:
|
||||
def __init__(
|
||||
self,
|
||||
apple_music_api: AppleMusicApi,
|
||||
itunes_api: ItunesApi,
|
||||
song: AppleMusicSongInterface,
|
||||
music_video: AppleMusicMusicVideoInterface,
|
||||
uploaded_video: AppleMusicUploadedVideoInterface,
|
||||
artist_select_media_type_function: (
|
||||
Callable[[list[ArtistMediaType], dict], ArtistMediaType | None] | None
|
||||
) = None,
|
||||
artist_select_items_function: (
|
||||
Callable[[ArtistMediaType, list[dict]], list[dict] | None] | None
|
||||
) = None,
|
||||
flat_filter_function: Callable[[dict], Any] | None = None,
|
||||
concurrency: int = 1,
|
||||
disallowed_media_types: list[str] | None = None,
|
||||
) -> None:
|
||||
self.apple_music_api = apple_music_api
|
||||
self.itunes_api = itunes_api
|
||||
self.song = song
|
||||
self.music_video = music_video
|
||||
self.uploaded_video = uploaded_video
|
||||
self.artist_select_media_type_function = artist_select_media_type_function
|
||||
self.artist_select_items_function = artist_select_items_function
|
||||
self.flat_filter_function = flat_filter_function
|
||||
self.concurrency = concurrency
|
||||
self.disallowed_media_types = disallowed_media_types
|
||||
|
||||
self.base = song.base
|
||||
|
||||
@staticmethod
|
||||
def get_media_id_of_library_media(library_media_metadata: dict) -> str:
|
||||
play_params = library_media_metadata["attributes"].get("playParams", {})
|
||||
return play_params.get("catalogId", library_media_metadata["id"])
|
||||
def get_url_info(url: str) -> AppleMusicUrlInfo | None:
|
||||
log = logger.bind(action="get_url_info", url=url)
|
||||
|
||||
@staticmethod
|
||||
def parse_date(date: str) -> datetime.datetime:
|
||||
return datetime.datetime.fromisoformat(date.split("Z")[0])
|
||||
match = VALID_URL_PATTERN.match(url)
|
||||
if not match:
|
||||
log.debug("invalid_url_pattern")
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
track_uri: str,
|
||||
track_id: str,
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKey:
|
||||
try:
|
||||
cdm_session = cdm.open()
|
||||
|
||||
pssh_obj = PSSH(track_uri.split(",")[-1])
|
||||
|
||||
challenge = base64.b64encode(
|
||||
await asyncio.to_thread(
|
||||
cdm.get_license_challenge, cdm_session, pssh_obj
|
||||
)
|
||||
).decode()
|
||||
license = await self.apple_music_api.get_license_exchange(
|
||||
track_id,
|
||||
track_uri,
|
||||
challenge,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(cdm.parse_license, cdm_session, license["license"])
|
||||
decryption_key_info = next(
|
||||
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
)
|
||||
finally:
|
||||
cdm.close(cdm_session)
|
||||
|
||||
decryption_key = DecryptionKey(
|
||||
key=decryption_key_info.key.hex(),
|
||||
kid=decryption_key_info.kid.hex,
|
||||
)
|
||||
logger.debug(f"Decryption key: {decryption_key}")
|
||||
|
||||
return decryption_key
|
||||
|
||||
def get_cover_url_template(self, metadata: dict, cover_format: CoverFormat) -> str:
|
||||
if cover_format == CoverFormat.RAW:
|
||||
cover_url_template = self._get_raw_cover_url(
|
||||
metadata["attributes"]["artwork"]["url"]
|
||||
)
|
||||
cover_url_template = metadata["attributes"]["artwork"]["url"]
|
||||
|
||||
logger.debug(f"Cover URL template: {cover_url_template}")
|
||||
return cover_url_template
|
||||
|
||||
def _get_raw_cover_url(self, cover_url_template: str) -> str:
|
||||
return re.sub(
|
||||
r"image/thumb/",
|
||||
"",
|
||||
re.sub(
|
||||
r"is1-ssl",
|
||||
"a1",
|
||||
cover_url_template,
|
||||
),
|
||||
)
|
||||
|
||||
def get_cover_url(
|
||||
self,
|
||||
cover_url_template: str,
|
||||
cover_size: int,
|
||||
cover_format: CoverFormat,
|
||||
) -> str:
|
||||
cover_url = re.sub(
|
||||
r"\{w\}x\{h\}([a-z]{2})\.jpg",
|
||||
(
|
||||
f"{cover_size}x{cover_size}bb.{cover_format.value}"
|
||||
if cover_format != CoverFormat.RAW
|
||||
else ""
|
||||
),
|
||||
cover_url_template,
|
||||
)
|
||||
|
||||
logger.debug(f"Cover URL: {cover_url}")
|
||||
return cover_url
|
||||
|
||||
@alru_cache()
|
||||
async def get_cover_file_extension(
|
||||
self,
|
||||
cover_url: str,
|
||||
cover_format: CoverFormat,
|
||||
) -> str | None:
|
||||
if cover_format != CoverFormat.RAW:
|
||||
return f".{cover_format.value}"
|
||||
|
||||
cover_url = self.get_cover_url(cover_url)
|
||||
cover_bytes = await self.get_cover_bytes(cover_url)
|
||||
if cover_bytes is None:
|
||||
return None
|
||||
|
||||
image_obj = Image.open(BytesIO(self.get_cover_bytes(cover_url)))
|
||||
image_format = image_obj.format.lower()
|
||||
return IMAGE_FILE_EXTENSION_MAP.get(
|
||||
image_format,
|
||||
f".{image_format.lower()}",
|
||||
url_match = AppleMusicUrlInfo(
|
||||
**match.groupdict(),
|
||||
)
|
||||
|
||||
@alru_cache()
|
||||
async def get_cover_bytes(self, cover_url: str) -> bytes | None:
|
||||
response = await get_response(cover_url, {200, 404})
|
||||
if response.status_code == 200:
|
||||
return response.content
|
||||
return None
|
||||
log.debug("success", url_info=url_match)
|
||||
|
||||
@alru_cache()
|
||||
async def get_media_date(
|
||||
return url_match
|
||||
|
||||
async def _run_flat_filter(self, media: AppleMusicMedia) -> None:
|
||||
if not self.flat_filter_function or not media.partial:
|
||||
return
|
||||
|
||||
result = self.flat_filter_function(media.media_metadata)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
if result:
|
||||
raise GamdlInterfaceFlatFilterExcludedError(media.media_id, result)
|
||||
|
||||
def _run_media_type_filter(self, media: AppleMusicMedia) -> None:
|
||||
if not self.disallowed_media_types or not media.partial:
|
||||
return
|
||||
|
||||
if media.media_metadata["type"] in self.disallowed_media_types:
|
||||
raise GamdlInterfaceMediaNotAllowedError(
|
||||
media.media_metadata["type"],
|
||||
media.media_id,
|
||||
)
|
||||
|
||||
async def _collect_generator(
|
||||
self, generator_or_coroutine: AsyncGenerator[AppleMusicMedia, None]
|
||||
) -> list[AppleMusicMedia]:
|
||||
results = []
|
||||
async for result in generator_or_coroutine:
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
async def _get_song_media(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> datetime.datetime | None:
|
||||
lookup_result = await self.itunes_api.get_lookup_result(media_id)
|
||||
if not lookup_result["results"]:
|
||||
return None
|
||||
index: int | None = None,
|
||||
total: int | None = None,
|
||||
media_metadata: dict | None = None,
|
||||
playlist_metadata: dict | None = None,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
media = AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
)
|
||||
|
||||
release_date = lookup_result["results"][0].get("releaseDate")
|
||||
if not release_date:
|
||||
return None
|
||||
if index is not None:
|
||||
media.index = index
|
||||
if total is not None:
|
||||
media.total = total
|
||||
|
||||
parsed_date = self.parse_date(release_date)
|
||||
logger.debug(f"Parsed media date: {parsed_date}")
|
||||
media.media_metadata = media_metadata
|
||||
media.playlist_metadata = playlist_metadata
|
||||
|
||||
return parsed_date
|
||||
try:
|
||||
async for media in self.song.get_media(media):
|
||||
yield media
|
||||
|
||||
self._run_media_type_filter(media)
|
||||
await self._run_flat_filter(media)
|
||||
except Exception as e:
|
||||
media.partial = False
|
||||
media.error = e
|
||||
yield media
|
||||
return
|
||||
|
||||
async def _get_music_video_media(
|
||||
self,
|
||||
media_id: str,
|
||||
index: int | None = None,
|
||||
total: int | None = None,
|
||||
media_metadata: dict | None = None,
|
||||
playlist_metadata: dict | None = None,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
media = AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
)
|
||||
|
||||
if index is not None:
|
||||
media.index = index
|
||||
if total is not None:
|
||||
media.total = total
|
||||
|
||||
media.media_metadata = media_metadata
|
||||
media.playlist_metadata = playlist_metadata
|
||||
|
||||
try:
|
||||
async for media in self.music_video.get_media(media):
|
||||
yield media
|
||||
|
||||
self._run_media_type_filter(media)
|
||||
await self._run_flat_filter(media)
|
||||
except Exception as e:
|
||||
media.partial = False
|
||||
media.error = e
|
||||
yield media
|
||||
return
|
||||
|
||||
async def _get_uploaded_video_media(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
media = AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
)
|
||||
|
||||
try:
|
||||
async for media in self.music_video.get_media(media):
|
||||
yield
|
||||
|
||||
self._run_media_type_filter(media)
|
||||
await self._run_flat_filter(media)
|
||||
except Exception as e:
|
||||
media.partial = False
|
||||
media.error = e
|
||||
yield media
|
||||
return
|
||||
|
||||
async def _get_album_media(
|
||||
self,
|
||||
media_id: str,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
base_media = AppleMusicMedia(media_id)
|
||||
|
||||
try:
|
||||
base_media.media_metadata = (
|
||||
await self.base.apple_music_api.get_library_album(
|
||||
media_id,
|
||||
)
|
||||
if is_library
|
||||
else await self.base.apple_music_api.get_album(
|
||||
media_id,
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
self._run_media_type_filter(base_media)
|
||||
await self._run_flat_filter(base_media)
|
||||
except Exception as e:
|
||||
base_media.partial = False
|
||||
base_media.error = e
|
||||
yield base_media
|
||||
return
|
||||
|
||||
yield base_media
|
||||
|
||||
tracks = base_media.media_metadata["relationships"]["tracks"]["data"]
|
||||
tasks = [
|
||||
(
|
||||
self._get_song_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
total=base_media.media_metadata["attributes"]["trackCount"],
|
||||
media_metadata=track,
|
||||
)
|
||||
if track["type"] in {"songs", "library-songs"}
|
||||
else self._get_music_video_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
total=base_media.media_metadata["attributes"]["trackCount"],
|
||||
media_metadata=track,
|
||||
)
|
||||
)
|
||||
for index, track in enumerate(tracks)
|
||||
]
|
||||
|
||||
if self.concurrency == 1:
|
||||
for task in tasks:
|
||||
async for media in task:
|
||||
yield media
|
||||
else:
|
||||
collected_tasks = [self._collect_generator(task) for task in tasks]
|
||||
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
|
||||
for batch in batches:
|
||||
for media in batch:
|
||||
yield media
|
||||
|
||||
async def _get_playlist_media(
|
||||
self,
|
||||
media_id: str,
|
||||
is_library: bool = False,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
base_media = AppleMusicMedia(media_id)
|
||||
|
||||
try:
|
||||
base_media.media_metadata = (
|
||||
await self.base.apple_music_api.get_library_playlist(
|
||||
media_id,
|
||||
)
|
||||
if is_library
|
||||
else await self.base.apple_music_api.get_playlist(
|
||||
media_id,
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
self._run_media_type_filter(base_media)
|
||||
await self._run_flat_filter(base_media)
|
||||
|
||||
tracks = base_media.media_metadata["relationships"]["tracks"]["data"]
|
||||
next_uri = base_media.media_metadata["relationships"]["tracks"].get("next")
|
||||
href_uri = base_media.media_metadata["relationships"]["tracks"].get("href")
|
||||
while next_uri:
|
||||
extended_data = await self.base.apple_music_api.get_extended_api_data(
|
||||
next_uri,
|
||||
href_uri,
|
||||
)
|
||||
tracks.extend(extended_data["data"])
|
||||
next_uri = extended_data.get("next")
|
||||
except Exception as e:
|
||||
base_media.partial = False
|
||||
base_media.error = e
|
||||
yield base_media
|
||||
return
|
||||
|
||||
yield base_media
|
||||
|
||||
tasks = [
|
||||
(
|
||||
self._get_song_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
total=base_media.media_metadata["attributes"]["trackCount"],
|
||||
media_metadata=track,
|
||||
playlist_metadata=base_media.media_metadata,
|
||||
)
|
||||
if track["type"] in {"songs", "library-songs"}
|
||||
else self._get_music_video_media(
|
||||
media_id=track["id"],
|
||||
index=index,
|
||||
total=base_media.media_metadata["attributes"]["trackCount"],
|
||||
media_metadata=track,
|
||||
playlist_metadata=base_media.media_metadata,
|
||||
)
|
||||
)
|
||||
for index, track in enumerate(tracks)
|
||||
]
|
||||
|
||||
if self.concurrency == 1:
|
||||
for task in tasks:
|
||||
async for media in task:
|
||||
yield media
|
||||
else:
|
||||
collected_tasks = [self._collect_generator(task) for task in tasks]
|
||||
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
|
||||
for batch in batches:
|
||||
for media in batch:
|
||||
yield media
|
||||
|
||||
async def _get_artist_media(
|
||||
self,
|
||||
media_id: str,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
base_media = AppleMusicMedia(media_id)
|
||||
|
||||
try:
|
||||
base_media.media_metadata = (
|
||||
await self.base.apple_music_api.get_artist(
|
||||
media_id,
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
self._run_media_type_filter(base_media)
|
||||
await self._run_flat_filter(base_media)
|
||||
|
||||
if self.artist_select_media_type_function:
|
||||
artist_media_type = self.artist_select_media_type_function(
|
||||
list(ArtistMediaType),
|
||||
base_media.media_metadata,
|
||||
)
|
||||
if asyncio.iscoroutine(artist_media_type):
|
||||
artist_media_type = await artist_media_type
|
||||
else:
|
||||
artist_media_type = list(ArtistMediaType)[0]
|
||||
|
||||
relation_key, type_key = artist_media_type.path_key
|
||||
|
||||
items_relation = base_media.media_metadata.get(relation_key, {}).get(
|
||||
type_key, {}
|
||||
)
|
||||
items = items_relation.get("data", [])
|
||||
if not items:
|
||||
raise GamdlInterfaceArtistMediaTypeError(
|
||||
base_media.media_id,
|
||||
str(artist_media_type),
|
||||
)
|
||||
|
||||
next_uri = items_relation.get("next")
|
||||
href_uri = items_relation.get("href")
|
||||
while next_uri:
|
||||
extended_data = await self.base.apple_music_api.get_extended_api_data(
|
||||
next_uri,
|
||||
href_uri,
|
||||
)
|
||||
items.extend(extended_data.get("data", []))
|
||||
next_uri = extended_data.get("next")
|
||||
except Exception as e:
|
||||
yield AppleMusicMedia(
|
||||
media_id=media_id,
|
||||
media_metadata=None,
|
||||
error=e,
|
||||
)
|
||||
return
|
||||
|
||||
yield base_media
|
||||
|
||||
if self.artist_select_items_function:
|
||||
selected_items = self.artist_select_items_function(
|
||||
artist_media_type,
|
||||
items,
|
||||
)
|
||||
if asyncio.iscoroutine(selected_items):
|
||||
selected_items = await selected_items
|
||||
else:
|
||||
selected_items = items[:1]
|
||||
|
||||
tasks = []
|
||||
for index, item in enumerate(selected_items):
|
||||
if item["type"] in {"songs", "library-songs"}:
|
||||
tasks.append(
|
||||
self._get_song_media(
|
||||
media_id=item["id"],
|
||||
index=index,
|
||||
total=len(selected_items),
|
||||
media_metadata=item,
|
||||
)
|
||||
)
|
||||
elif item["type"] in {"albums", "library-albums"}:
|
||||
tasks.append(
|
||||
self._get_album_media(
|
||||
media_id=item["id"],
|
||||
)
|
||||
)
|
||||
else:
|
||||
tasks.append(
|
||||
self._get_music_video_media(
|
||||
media_id=item["id"],
|
||||
index=index,
|
||||
total=len(selected_items),
|
||||
media_metadata=item,
|
||||
)
|
||||
)
|
||||
|
||||
if self.concurrency == 1:
|
||||
for task in tasks:
|
||||
async for media in task:
|
||||
yield media
|
||||
else:
|
||||
collected_tasks = [self._collect_generator(task) for task in tasks]
|
||||
batches = await safe_gather(*collected_tasks, limit=self.concurrency)
|
||||
for batch in batches:
|
||||
for media in batch:
|
||||
yield media
|
||||
|
||||
async def get_media_from_url(
|
||||
self,
|
||||
url: str,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
url_info = self.get_url_info(url)
|
||||
|
||||
if not url_info:
|
||||
raise GamdlInterfaceUrlParseError(url)
|
||||
|
||||
if self.disallowed_media_types and url_info.type in self.disallowed_media_types:
|
||||
raise GamdlInterfaceMediaNotAllowedError(
|
||||
url_info.type,
|
||||
)
|
||||
|
||||
if url_info.type == "song" or url_info.sub_id:
|
||||
async for media in self._get_song_media(
|
||||
media_id=url_info.sub_id or url_info.id,
|
||||
index=0,
|
||||
total=1,
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "music-video":
|
||||
async for media in self._get_music_video_media(
|
||||
media_id=url_info.id,
|
||||
index=0,
|
||||
total=1,
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "album" or url_info.library_type == "albums":
|
||||
async for media in self._get_album_media(
|
||||
media_id=url_info.library_id or url_info.id,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "playlist" or url_info.library_type == "playlist":
|
||||
async for media in self._get_playlist_media(
|
||||
media_id=url_info.library_id or url_info.id,
|
||||
is_library=bool(url_info.library_type),
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "post":
|
||||
async for media in self._get_uploaded_video_media(
|
||||
media_id=url_info.id,
|
||||
):
|
||||
yield media
|
||||
|
||||
elif url_info.type == "artist":
|
||||
async for media in self._get_artist_media(
|
||||
media_id=url_info.id,
|
||||
):
|
||||
yield media
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
import logging
|
||||
import urllib.parse
|
||||
|
||||
import m3u8
|
||||
from async_lru import alru_cache
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
from pywidevine import Cdm
|
||||
|
||||
from ..utils import get_response
|
||||
from .constants import MP4_FORMAT_CODECS
|
||||
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
|
||||
from .interface import AppleMusicInterface
|
||||
from .types import DecryptionKeyAv, MediaFileFormat, MediaTags, StreamInfo, StreamInfoAv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleMusicMusicVideoInterface(AppleMusicInterface):
|
||||
def __init__(self, interface: AppleMusicInterface):
|
||||
self.__dict__.update(interface.__dict__)
|
||||
|
||||
async def get_itunes_page_metadata(
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
) -> dict:
|
||||
alt_id = self.get_alt_id(music_video_metadata)
|
||||
itunes_page = await self.itunes_api.get_itunes_page(
|
||||
"music-video",
|
||||
alt_id,
|
||||
)
|
||||
return itunes_page["storePlatformData"]["product-dv"]["results"][alt_id]
|
||||
|
||||
def get_m3u8_master_url_from_webplayback(self, webplayback: dict) -> str:
|
||||
m3u8_master_url = webplayback["hls-playlist-url"]
|
||||
return m3u8_master_url
|
||||
|
||||
def get_m3u8_master_url_from_itunes_page_metadata(
|
||||
self,
|
||||
itunes_page_metadata: dict,
|
||||
) -> dict:
|
||||
stream_url = itunes_page_metadata["offers"][0]["assets"][0]["hlsUrl"]
|
||||
|
||||
url_parts = urllib.parse.urlparse(stream_url)
|
||||
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
|
||||
query.update({"aec": "HD", "dsid": "1"})
|
||||
|
||||
m3u8_master_url = url_parts._replace(
|
||||
query=urllib.parse.urlencode(query, doseq=True)
|
||||
).geturl()
|
||||
|
||||
return m3u8_master_url
|
||||
|
||||
def get_alt_id(self, metadata: dict) -> str | None:
|
||||
music_video_url = metadata["attributes"].get("url")
|
||||
if music_video_url is None:
|
||||
return None
|
||||
|
||||
alt_id = music_video_url.split("/")[-1].split("?")[0]
|
||||
logger.debug(f"Alt ID: {alt_id}")
|
||||
|
||||
return alt_id
|
||||
|
||||
@alru_cache()
|
||||
async def get_album(
|
||||
self,
|
||||
collection_id: int,
|
||||
) -> dict | None:
|
||||
album_response = await self.apple_music_api.get_album(collection_id)
|
||||
if not album_response:
|
||||
return None
|
||||
return album_response["data"][0]
|
||||
|
||||
async def get_tags(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
) -> MediaTags:
|
||||
alt_id = self.get_alt_id(metadata)
|
||||
lookup_metadata = (await self.itunes_api.get_lookup_result(alt_id))["results"]
|
||||
|
||||
explicitness = lookup_metadata[0]["trackExplicitness"]
|
||||
if explicitness == "notExplicit":
|
||||
rating = MediaRating.NONE
|
||||
elif explicitness == "explicit":
|
||||
rating = MediaRating.EXPLICIT
|
||||
else:
|
||||
rating = MediaRating.CLEAN
|
||||
|
||||
tags = MediaTags(
|
||||
artist=lookup_metadata[0]["artistName"],
|
||||
artist_id=int(lookup_metadata[0]["artistId"]),
|
||||
copyright=itunes_page_metadata.get("copyright"),
|
||||
date=self.parse_date(lookup_metadata[0]["releaseDate"]),
|
||||
genre=lookup_metadata[0]["primaryGenreName"],
|
||||
genre_id=int(itunes_page_metadata["genres"][0]["genreId"]),
|
||||
media_type=MediaType.MUSIC_VIDEO,
|
||||
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
|
||||
title=lookup_metadata[0]["trackCensoredName"],
|
||||
title_id=int(metadata["id"]),
|
||||
rating=rating,
|
||||
)
|
||||
|
||||
if len(lookup_metadata) > 1:
|
||||
album = await self.get_album(itunes_page_metadata["collectionId"])
|
||||
if not album:
|
||||
return tags
|
||||
|
||||
tags.album = lookup_metadata[1]["collectionCensoredName"]
|
||||
tags.album_artist = lookup_metadata[1]["artistName"]
|
||||
tags.album_id = int(itunes_page_metadata["collectionId"])
|
||||
tags.disc = lookup_metadata[0]["discNumber"]
|
||||
tags.disc_total = lookup_metadata[0]["discCount"]
|
||||
tags.compilation = album["attributes"]["isCompilation"]
|
||||
tags.track = lookup_metadata[0]["trackNumber"]
|
||||
tags.track_total = lookup_metadata[0]["trackCount"]
|
||||
|
||||
logger.debug(f"Tags: {tags}")
|
||||
|
||||
return tags
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
codec_priority: list[MusicVideoCodec],
|
||||
resolution: MusicVideoResolution,
|
||||
) -> StreamInfoAv:
|
||||
alt_video_id = self.get_alt_id(metadata)
|
||||
if alt_video_id == metadata["id"]:
|
||||
m3u8_master_url = self.get_m3u8_master_url_from_itunes_page_metadata(
|
||||
itunes_page_metadata,
|
||||
)
|
||||
else:
|
||||
webplayback_response = await self.apple_music_api.get_webplayback(
|
||||
metadata["id"]
|
||||
)
|
||||
m3u8_master_url = self.get_m3u8_master_url_from_webplayback(
|
||||
webplayback_response["songList"][0],
|
||||
)
|
||||
|
||||
playlist_master_m3u8_obj = m3u8.loads(
|
||||
(await get_response(m3u8_master_url)).text
|
||||
)
|
||||
playlist_master_m3u8_obj.base_uri = m3u8_master_url.rpartition("/")[0]
|
||||
stream_info_video = await self.get_stream_info_video(
|
||||
playlist_master_m3u8_obj,
|
||||
codec_priority,
|
||||
resolution,
|
||||
)
|
||||
stream_info_audio = await self.get_stream_info_audio(
|
||||
playlist_master_m3u8_obj.data,
|
||||
codec_priority,
|
||||
)
|
||||
if not stream_info_video or not stream_info_audio:
|
||||
return None
|
||||
|
||||
use_mp4 = any(
|
||||
stream_info_video.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
) or any(
|
||||
stream_info_audio.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
)
|
||||
if use_mp4:
|
||||
file_format = MediaFileFormat.MP4
|
||||
else:
|
||||
file_format = MediaFileFormat.M4V
|
||||
|
||||
stream_info = StreamInfoAv(
|
||||
video_track=stream_info_video,
|
||||
audio_track=stream_info_audio,
|
||||
file_format=file_format,
|
||||
)
|
||||
logger.debug(f"Stream info: {stream_info}")
|
||||
|
||||
return stream_info
|
||||
|
||||
def get_video_playlist_from_resolution(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
codec_priority: list[MusicVideoCodec],
|
||||
resolution: MusicVideoResolution,
|
||||
) -> m3u8.Playlist | None:
|
||||
playlist_results = []
|
||||
for codec_index, codec in enumerate(codec_priority):
|
||||
for playlist in video_playlists:
|
||||
if playlist.stream_info.codecs.startswith(codec.fourcc()):
|
||||
playlist_results.append((codec_index, playlist))
|
||||
|
||||
if not playlist_results:
|
||||
return None
|
||||
|
||||
def sort_key(
|
||||
item: tuple[int, m3u8.Playlist],
|
||||
) -> tuple[bool, int, int, int, int]:
|
||||
codec_index, playlist = item
|
||||
playlist_resolution = playlist.stream_info.resolution[-1]
|
||||
bandwidth = playlist.stream_info.bandwidth
|
||||
exceeds_resolution = playlist_resolution > int(resolution)
|
||||
resolution_difference = abs(playlist_resolution - int(resolution))
|
||||
|
||||
return (
|
||||
exceeds_resolution,
|
||||
resolution_difference,
|
||||
codec_index,
|
||||
-playlist_resolution,
|
||||
-bandwidth,
|
||||
)
|
||||
|
||||
playlist_results.sort(key=sort_key)
|
||||
return playlist_results[0][1]
|
||||
|
||||
def get_best_stereo_audio_playlist(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict | None:
|
||||
audio_playlist = next(
|
||||
(
|
||||
media
|
||||
for media in playlist_master_data["media"]
|
||||
if media["group_id"] == "audio-stereo-256"
|
||||
),
|
||||
None,
|
||||
)
|
||||
return audio_playlist
|
||||
|
||||
async def get_video_playlist_from_user(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
) -> m3u8.Playlist:
|
||||
choices = [
|
||||
Choice(
|
||||
name=" | ".join(
|
||||
[
|
||||
playlist.stream_info.codecs[:4],
|
||||
"x".join(str(v) for v in playlist.stream_info.resolution),
|
||||
str(playlist.stream_info.bandwidth),
|
||||
]
|
||||
),
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in video_playlists
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which video codec to download: (Codec | Resolution | Bitrate)",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
async def get_audio_playlist_from_user(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["group_id"],
|
||||
value=playlist,
|
||||
)
|
||||
for playlist in playlist_master_data["media"]
|
||||
if playlist.get("uri")
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which audio codec to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return selected
|
||||
|
||||
def _get_key_by_format(
|
||||
self,
|
||||
m3u8_obj: m3u8.M3U8,
|
||||
key_format: str,
|
||||
) -> str:
|
||||
return next(
|
||||
(key for key in m3u8_obj.keys if key.keyformat == key_format),
|
||||
None,
|
||||
).uri
|
||||
|
||||
def get_widevine_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
|
||||
)
|
||||
|
||||
def get_playready_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.microsoft.playready",
|
||||
)
|
||||
|
||||
def get_fairplay_key(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
|
||||
async def get_stream_info_video(
|
||||
self,
|
||||
playlist_master_m3u8_obj: m3u8.M3U8,
|
||||
codec_priority: list[MusicVideoCodec],
|
||||
resolution: MusicVideoResolution,
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in codec_priority:
|
||||
playlist = self.get_video_playlist_from_resolution(
|
||||
playlist_master_m3u8_obj.playlists,
|
||||
codec_priority,
|
||||
resolution,
|
||||
)
|
||||
else:
|
||||
playlist = await self.get_video_playlist_from_user(
|
||||
playlist_master_m3u8_obj.playlists
|
||||
)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist.uri
|
||||
stream_info.codec = playlist.stream_info.codecs
|
||||
stream_info.width, stream_info.height = playlist.stream_info.resolution
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self.get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self.get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self.get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def get_stream_info_audio(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
codec_priority: list[MusicVideoCodec],
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in codec_priority:
|
||||
playlist = self.get_best_stereo_audio_playlist(playlist_master_data)
|
||||
else:
|
||||
playlist = await self.get_audio_playlist_from_user(playlist_master_data)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["group_id"]
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self.get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self.get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self.get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKeyAv:
|
||||
decryption_key_video = await AppleMusicInterface.get_decryption_key(
|
||||
self,
|
||||
stream_info.video_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
cdm,
|
||||
)
|
||||
decryption_key_audio = await AppleMusicInterface.get_decryption_key(
|
||||
self,
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
cdm,
|
||||
)
|
||||
|
||||
return DecryptionKeyAv(
|
||||
video_track=decryption_key_video,
|
||||
audio_track=decryption_key_audio,
|
||||
)
|
||||
@@ -1,86 +0,0 @@
|
||||
import logging
|
||||
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
|
||||
from ..interface.enums import UploadedVideoQuality
|
||||
from ..interface.types import MediaTags
|
||||
from .constants import UPLOADED_VIDEO_QUALITY_RANK
|
||||
from .interface import AppleMusicInterface
|
||||
from .types import MediaFileFormat, StreamInfo, StreamInfoAv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoInterface(AppleMusicInterface):
|
||||
def __init__(self, interface: AppleMusicInterface):
|
||||
self.__dict__.update(interface.__dict__)
|
||||
|
||||
def get_stream_url_best(self, metadata: dict) -> str:
|
||||
best_quality = next(
|
||||
(
|
||||
quality
|
||||
for quality in UPLOADED_VIDEO_QUALITY_RANK
|
||||
if metadata["attributes"]["assetTokens"].get(quality)
|
||||
),
|
||||
None,
|
||||
)
|
||||
return metadata["attributes"]["assetTokens"][best_quality]
|
||||
|
||||
async def get_stream_url_from_user(self, metadata: dict) -> str:
|
||||
qualities = list(metadata["attributes"]["assetTokens"].keys())
|
||||
choices = [
|
||||
Choice(
|
||||
name=quality,
|
||||
value=quality,
|
||||
)
|
||||
for quality in qualities
|
||||
]
|
||||
selected = await inquirer.select(
|
||||
message="Select which quality to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
|
||||
return metadata["attributes"]["assetTokens"][selected]
|
||||
|
||||
async def get_stream_url(
|
||||
self, metadata: dict, quality: UploadedVideoQuality
|
||||
) -> str:
|
||||
if quality == UploadedVideoQuality.BEST:
|
||||
stream_url = self.get_stream_url_best(metadata)
|
||||
|
||||
if quality == UploadedVideoQuality.ASK:
|
||||
stream_url = await self.get_stream_url_from_user(metadata)
|
||||
|
||||
logger.debug(f"Stream URL: {stream_url}")
|
||||
|
||||
return stream_url
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
metadata: dict,
|
||||
quality: UploadedVideoQuality,
|
||||
) -> StreamInfo:
|
||||
stream_url = await self.get_stream_url(metadata, quality)
|
||||
stream_info = StreamInfoAv(
|
||||
file_format=MediaFileFormat.M4V,
|
||||
video_track=StreamInfo(
|
||||
stream_url=stream_url,
|
||||
),
|
||||
)
|
||||
return stream_info
|
||||
|
||||
def get_tags(self, metadata: dict) -> MediaTags:
|
||||
attributes = metadata["attributes"]
|
||||
upload_date = attributes.get("uploadDate")
|
||||
|
||||
tags = MediaTags(
|
||||
artist=attributes.get("artistName"),
|
||||
date=self.parse_date(upload_date) if upload_date else None,
|
||||
title=attributes.get("name"),
|
||||
title_id=int(metadata["id"]),
|
||||
storefront=int(self.itunes_api.storefront_id.split("-")[0]),
|
||||
)
|
||||
logger.debug(f"Tags: {tags}")
|
||||
|
||||
return tags
|
||||
@@ -0,0 +1,429 @@
|
||||
import asyncio
|
||||
import urllib.parse
|
||||
from typing import AsyncGenerator, Callable
|
||||
|
||||
import m3u8
|
||||
import structlog
|
||||
|
||||
from .base import AppleMusicBaseInterface
|
||||
from .constants import MP4_FORMAT_CODECS
|
||||
from .enums import MediaRating, MediaType, MusicVideoCodec, MusicVideoResolution
|
||||
from .exceptions import (
|
||||
GamdlInterfaceDecryptionNotAvailableError,
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
)
|
||||
from .types import (
|
||||
AppleMusicMedia,
|
||||
DecryptionKeyAv,
|
||||
MediaFileFormat,
|
||||
MediaTags,
|
||||
StreamInfo,
|
||||
StreamInfoAv,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicMusicVideoInterface:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseInterface,
|
||||
resolution: MusicVideoResolution = MusicVideoResolution.R1080P,
|
||||
codec_priority: list[MusicVideoCodec] = [
|
||||
MusicVideoCodec.H264,
|
||||
MusicVideoCodec.H265,
|
||||
],
|
||||
ask_video_codec_function: (
|
||||
Callable[[list[m3u8.Playlist]], dict | None] | None
|
||||
) = None,
|
||||
ask_audio_codec_function: Callable[[list[dict]], dict | None] | None = None,
|
||||
):
|
||||
self.base = base
|
||||
self.resolution = resolution
|
||||
self.codec_priority = codec_priority
|
||||
self.ask_video_codec_function = ask_video_codec_function
|
||||
self.ask_audio_codec_function = ask_audio_codec_function
|
||||
|
||||
async def get_itunes_page_metadata(
|
||||
self,
|
||||
music_video_metadata: dict,
|
||||
) -> dict:
|
||||
url_media_id = self.base.parse_media_id_from_url(music_video_metadata)
|
||||
itunes_page = await self.base.itunes_api.get_itunes_page(
|
||||
"music-video",
|
||||
url_media_id,
|
||||
)
|
||||
return itunes_page["storePlatformData"]["product-dv"]["results"][url_media_id]
|
||||
|
||||
def _get_m3u8_master_url_from_webplayback(self, webplayback: dict) -> str:
|
||||
m3u8_master_url = webplayback["hls-playlist-url"]
|
||||
return m3u8_master_url
|
||||
|
||||
def _get_m3u8_master_url_from_itunes_page_metadata(
|
||||
self,
|
||||
itunes_page_metadata: dict,
|
||||
) -> str | None:
|
||||
stream_url = itunes_page_metadata["offers"][0]["assets"][0].get("hlsUrl")
|
||||
if not stream_url:
|
||||
return None
|
||||
|
||||
url_parts = urllib.parse.urlparse(stream_url)
|
||||
query = urllib.parse.parse_qs(url_parts.query, keep_blank_values=True)
|
||||
query.update({"aec": "HD", "dsid": "1"})
|
||||
|
||||
m3u8_master_url = url_parts._replace(
|
||||
query=urllib.parse.urlencode(query, doseq=True)
|
||||
).geturl()
|
||||
|
||||
return m3u8_master_url
|
||||
|
||||
async def get_tags(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
) -> MediaTags:
|
||||
log = logger.bind(
|
||||
action="get_music_video_tags",
|
||||
media_id=self.base.parse_catalog_media_id(metadata),
|
||||
)
|
||||
|
||||
url_media_id = self.base.parse_media_id_from_url(metadata)
|
||||
lookup_metadata = (await self.base.itunes_api.get_lookup_result(url_media_id))[
|
||||
"results"
|
||||
]
|
||||
|
||||
explicitness = lookup_metadata[0]["trackExplicitness"]
|
||||
if explicitness == "notExplicit":
|
||||
rating = MediaRating.NONE
|
||||
elif explicitness == "explicit":
|
||||
rating = MediaRating.EXPLICIT
|
||||
else:
|
||||
rating = MediaRating.CLEAN
|
||||
|
||||
tags = MediaTags(
|
||||
artist=lookup_metadata[0]["artistName"],
|
||||
artist_id=int(lookup_metadata[0]["artistId"]),
|
||||
copyright=itunes_page_metadata.get("copyright"),
|
||||
date=self.base.parse_date(lookup_metadata[0]["releaseDate"]),
|
||||
genre=lookup_metadata[0]["primaryGenreName"],
|
||||
genre_id=int(itunes_page_metadata["genres"][0]["genreId"]),
|
||||
media_type=MediaType.MUSIC_VIDEO,
|
||||
storefront=self.base.itunes_api.storefront_id,
|
||||
title=lookup_metadata[0]["trackCensoredName"],
|
||||
title_id=int(metadata["id"]),
|
||||
rating=rating,
|
||||
)
|
||||
|
||||
if len(lookup_metadata) > 1:
|
||||
album = await self.base.get_album_cached(
|
||||
itunes_page_metadata["collectionId"]
|
||||
)
|
||||
if not album:
|
||||
return tags
|
||||
|
||||
tags.album = lookup_metadata[1]["collectionCensoredName"]
|
||||
tags.album_artist = lookup_metadata[1]["artistName"]
|
||||
tags.album_id = int(itunes_page_metadata["collectionId"])
|
||||
tags.disc = lookup_metadata[0]["discNumber"]
|
||||
tags.disc_total = lookup_metadata[0]["discCount"]
|
||||
tags.compilation = album["attributes"]["isCompilation"]
|
||||
tags.track = lookup_metadata[0]["trackNumber"]
|
||||
tags.track_total = lookup_metadata[0]["trackCount"]
|
||||
|
||||
log.debug("success", tags=tags)
|
||||
|
||||
return tags
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
metadata: dict,
|
||||
itunes_page_metadata: dict,
|
||||
) -> StreamInfoAv | None:
|
||||
log = logger.bind(
|
||||
action="get_music_video_stream_info",
|
||||
media_id=self.base.parse_catalog_media_id(metadata),
|
||||
)
|
||||
|
||||
url_media_id = self.base.parse_media_id_from_url(metadata)
|
||||
m3u8_master_url = None
|
||||
|
||||
if url_media_id == metadata["id"]:
|
||||
m3u8_master_url = self._get_m3u8_master_url_from_itunes_page_metadata(
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
if not m3u8_master_url:
|
||||
webplayback_response = await self.base.apple_music_api.get_webplayback(
|
||||
metadata["id"]
|
||||
)
|
||||
m3u8_master_url = self._get_m3u8_master_url_from_webplayback(
|
||||
webplayback_response["songList"][0],
|
||||
)
|
||||
|
||||
playlist_master_m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(m3u8_master_url)).text
|
||||
)
|
||||
playlist_master_m3u8_obj.base_uri = m3u8_master_url.rpartition("/")[0]
|
||||
stream_info_video = await self._get_stream_info_video(playlist_master_m3u8_obj)
|
||||
stream_info_audio = await self._get_stream_info_audio(
|
||||
playlist_master_m3u8_obj.data,
|
||||
)
|
||||
if not stream_info_video or not stream_info_audio:
|
||||
return None
|
||||
|
||||
use_mp4 = any(
|
||||
stream_info_video.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
) or any(
|
||||
stream_info_audio.codec.startswith(codec) for codec in MP4_FORMAT_CODECS
|
||||
)
|
||||
if use_mp4:
|
||||
file_format = MediaFileFormat.MP4
|
||||
else:
|
||||
file_format = MediaFileFormat.M4V
|
||||
|
||||
stream_info = StreamInfoAv(
|
||||
video_track=stream_info_video,
|
||||
audio_track=stream_info_audio,
|
||||
file_format=file_format,
|
||||
)
|
||||
|
||||
log.debug("success", stream_info=stream_info)
|
||||
|
||||
return stream_info
|
||||
|
||||
def _get_video_playlist_from_resolution(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
) -> m3u8.Playlist | None:
|
||||
playlist_results = []
|
||||
for codec_index, codec in enumerate(self.codec_priority):
|
||||
for playlist in video_playlists:
|
||||
if playlist.stream_info.codecs.startswith(codec.fourcc()):
|
||||
playlist_results.append((codec_index, playlist))
|
||||
|
||||
if not playlist_results:
|
||||
return None
|
||||
|
||||
def sort_key(
|
||||
item: tuple[int, m3u8.Playlist],
|
||||
) -> tuple[bool, int, int, int, int]:
|
||||
codec_index, playlist = item
|
||||
playlist_resolution = playlist.stream_info.resolution[-1]
|
||||
bandwidth = playlist.stream_info.bandwidth
|
||||
exceeds_resolution = playlist_resolution > int(self.resolution)
|
||||
resolution_difference = abs(playlist_resolution - int(self.resolution))
|
||||
|
||||
return (
|
||||
exceeds_resolution,
|
||||
resolution_difference,
|
||||
codec_index,
|
||||
-playlist_resolution,
|
||||
-bandwidth,
|
||||
)
|
||||
|
||||
playlist_results.sort(key=sort_key)
|
||||
return playlist_results[0][1]
|
||||
|
||||
def _get_best_stereo_audio_playlist(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict | None:
|
||||
audio_playlist = next(
|
||||
(
|
||||
media
|
||||
for media in playlist_master_data["media"]
|
||||
if media["group_id"] == "audio-stereo-256"
|
||||
),
|
||||
None,
|
||||
)
|
||||
return audio_playlist
|
||||
|
||||
async def _get_video_playlist_from_user(
|
||||
self,
|
||||
video_playlists: list[m3u8.Playlist],
|
||||
) -> m3u8.Playlist | None:
|
||||
if self.ask_video_codec_function:
|
||||
video_playlist = self.ask_video_codec_function(video_playlists)
|
||||
if asyncio.iscoroutine(video_playlist):
|
||||
video_playlist = await video_playlist
|
||||
|
||||
return video_playlist
|
||||
|
||||
return None
|
||||
|
||||
async def _get_audio_playlist_from_user(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> dict | None:
|
||||
if self.ask_audio_codec_function:
|
||||
audio_playlist = self.ask_audio_codec_function(
|
||||
[
|
||||
playlist
|
||||
for playlist in playlist_master_data["media"]
|
||||
if playlist.get("uri")
|
||||
]
|
||||
)
|
||||
if asyncio.iscoroutine(audio_playlist):
|
||||
audio_playlist = await audio_playlist
|
||||
|
||||
return audio_playlist
|
||||
|
||||
return None
|
||||
|
||||
def _get_key_by_format(
|
||||
self,
|
||||
m3u8_obj: m3u8.M3U8,
|
||||
key_format: str,
|
||||
) -> str:
|
||||
return next(
|
||||
(key for key in m3u8_obj.keys if key.keyformat == key_format),
|
||||
None,
|
||||
).uri
|
||||
|
||||
def _get_widevine_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
|
||||
)
|
||||
|
||||
def _get_playready_pssh(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.microsoft.playready",
|
||||
)
|
||||
|
||||
def _get_fairplay_key(self, m3u8_obj: m3u8.M3U8) -> str:
|
||||
return self._get_key_by_format(
|
||||
m3u8_obj,
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
|
||||
async def _get_stream_info_video(
|
||||
self,
|
||||
playlist_master_m3u8_obj: m3u8.M3U8,
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in self.codec_priority:
|
||||
playlist = self._get_video_playlist_from_resolution(
|
||||
playlist_master_m3u8_obj.playlists,
|
||||
)
|
||||
else:
|
||||
playlist = await self._get_video_playlist_from_user(
|
||||
playlist_master_m3u8_obj.playlists
|
||||
)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist.uri
|
||||
stream_info.codec = playlist.stream_info.codecs
|
||||
stream_info.width, stream_info.height = playlist.stream_info.resolution
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self._get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self._get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self._get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def _get_stream_info_audio(
|
||||
self,
|
||||
playlist_master_data: dict,
|
||||
) -> StreamInfo | None:
|
||||
stream_info = StreamInfo()
|
||||
|
||||
if MusicVideoCodec.ASK not in self.codec_priority:
|
||||
playlist = self._get_best_stereo_audio_playlist(playlist_master_data)
|
||||
else:
|
||||
playlist = await self._get_audio_playlist_from_user(playlist_master_data)
|
||||
|
||||
if not playlist:
|
||||
return None
|
||||
|
||||
stream_info.stream_url = playlist["uri"]
|
||||
stream_info.codec = playlist["group_id"]
|
||||
|
||||
playlist_m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = self._get_widevine_pssh(playlist_m3u8_obj)
|
||||
stream_info.fairplay_key = self._get_fairplay_key(playlist_m3u8_obj)
|
||||
stream_info.playready_pssh = self._get_playready_pssh(playlist_m3u8_obj)
|
||||
|
||||
return stream_info
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
) -> DecryptionKeyAv:
|
||||
decryption_key_video, decryption_key_audio = await asyncio.gather(
|
||||
self.base.get_decryption_key(
|
||||
stream_info.video_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
),
|
||||
self.base.get_decryption_key(
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
),
|
||||
)
|
||||
|
||||
return DecryptionKeyAv(
|
||||
video_track=decryption_key_video,
|
||||
audio_track=decryption_key_audio,
|
||||
)
|
||||
|
||||
async def get_media(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
if not media.media_metadata:
|
||||
media.media_metadata = (
|
||||
await self.base.apple_music_api.get_music_video(media.media_id)
|
||||
)["data"][0]
|
||||
|
||||
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
|
||||
|
||||
yield media
|
||||
|
||||
if not self.base.is_media_streamable(media.media_metadata):
|
||||
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
|
||||
|
||||
if media.playlist_metadata:
|
||||
media.playlist_tags = self.base.get_playlist_tags(
|
||||
media.playlist_metadata,
|
||||
media.index,
|
||||
)
|
||||
|
||||
media.cover = await self.base.get_cover(media.media_metadata)
|
||||
|
||||
itunes_page_metadata = await self.get_itunes_page_metadata(media.media_metadata)
|
||||
media.tags = await self.get_tags(
|
||||
media.media_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
|
||||
media.stream_info = await self.get_stream_info(
|
||||
media.media_metadata,
|
||||
itunes_page_metadata,
|
||||
)
|
||||
if not media.stream_info:
|
||||
raise GamdlInterfaceFormatNotAvailableError(
|
||||
media.media_id,
|
||||
self.codec_priority,
|
||||
)
|
||||
|
||||
if (
|
||||
not media.stream_info.video_track.widevine_pssh
|
||||
or not media.stream_info.audio_track.widevine_pssh
|
||||
):
|
||||
raise GamdlInterfaceDecryptionNotAvailableError(media.media_id)
|
||||
|
||||
media.decryption_key = await self.get_decryption_key(media.stream_info)
|
||||
|
||||
media.partial = False
|
||||
|
||||
yield media
|
||||
@@ -1,26 +1,26 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
from typing import AsyncGenerator, Callable
|
||||
from xml.dom import minidom
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import m3u8
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.base.control import Choice
|
||||
from mutagen.mp4 import MP4
|
||||
from pywidevine import PSSH, Cdm
|
||||
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||
import structlog
|
||||
|
||||
from ..utils import get_response
|
||||
from .base import AppleMusicBaseInterface
|
||||
from .constants import DRM_DEFAULT_KEY_MAPPING, MP4_FORMAT_CODECS, SONG_CODEC_REGEX_MAP
|
||||
from .enums import MediaRating, MediaType, SongCodec, SyncedLyricsFormat
|
||||
from .interface import AppleMusicInterface
|
||||
from .exceptions import (
|
||||
GamdlInterfaceDecryptionNotAvailableError,
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
)
|
||||
from .types import (
|
||||
DecryptionKey,
|
||||
AppleMusicMedia,
|
||||
DecryptionKeyAv,
|
||||
Lyrics,
|
||||
MediaFileFormat,
|
||||
@@ -29,19 +29,37 @@ from .types import (
|
||||
StreamInfoAv,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicSongInterface(AppleMusicInterface):
|
||||
def __init__(self, interface: AppleMusicInterface):
|
||||
self.__dict__.update(interface.__dict__)
|
||||
class AppleMusicSongInterface:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseInterface,
|
||||
synced_lyrics_format: SyncedLyricsFormat = SyncedLyricsFormat.LRC,
|
||||
codec_priority: list[SongCodec] = [SongCodec.AAC_LEGACY],
|
||||
use_album_date: bool = False,
|
||||
skip_stream_info: bool = False,
|
||||
ask_codec_function: Callable[[list[dict]], dict | None] | None = None,
|
||||
):
|
||||
self.base = base
|
||||
self.synced_lyrics_format = synced_lyrics_format
|
||||
self.codec_priority = codec_priority
|
||||
self.use_album_date = use_album_date
|
||||
self.skip_stream_info = skip_stream_info
|
||||
self.ask_codec_function = ask_codec_function
|
||||
|
||||
async def get_lyrics(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
synced_lyrics_format: SyncedLyricsFormat,
|
||||
) -> Lyrics | None:
|
||||
log = logger.bind(
|
||||
action="get_lyrics",
|
||||
song_id=self.base.parse_catalog_media_id(song_metadata),
|
||||
)
|
||||
|
||||
if not song_metadata["attributes"]["hasLyrics"]:
|
||||
log.debug("no_lyrics")
|
||||
return None
|
||||
|
||||
if (
|
||||
@@ -49,8 +67,8 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
or "lyrics" not in song_metadata["relationships"]
|
||||
):
|
||||
song_metadata = (
|
||||
await self.apple_music_api.get_song(
|
||||
self.get_media_id_of_library_media(song_metadata)
|
||||
await self.base.apple_music_api.get_song(
|
||||
self.base.parse_catalog_media_id(song_metadata)
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
@@ -68,16 +86,17 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
song_metadata["relationships"]["lyrics"]["data"][0]["attributes"][
|
||||
"ttml"
|
||||
],
|
||||
synced_lyrics_format,
|
||||
)
|
||||
logging.debug(f"Lyrics: {lyrics}")
|
||||
|
||||
log.debug("success", lyrics=lyrics)
|
||||
|
||||
return lyrics
|
||||
else:
|
||||
log.debug("no_lyrics_data")
|
||||
|
||||
def _get_lyrics(
|
||||
self,
|
||||
lyrics_ttml: str,
|
||||
synced_lyrics_format: SyncedLyricsFormat,
|
||||
) -> Lyrics:
|
||||
lyrics_ttml_et = ElementTree.fromstring(lyrics_ttml)
|
||||
unsynced_lyrics = []
|
||||
@@ -93,13 +112,13 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
stanza.append(p.text)
|
||||
|
||||
if p.attrib.get("begin"):
|
||||
if synced_lyrics_format == SyncedLyricsFormat.LRC:
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.LRC:
|
||||
synced_lyrics.append(self._get_lyrics_line_lrc(p))
|
||||
|
||||
if synced_lyrics_format == SyncedLyricsFormat.SRT:
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.SRT:
|
||||
synced_lyrics.append(self._get_lyrics_line_srt(index, p))
|
||||
|
||||
if synced_lyrics_format == SyncedLyricsFormat.TTML:
|
||||
if self.synced_lyrics_format == SyncedLyricsFormat.TTML:
|
||||
if not synced_lyrics:
|
||||
synced_lyrics.append(
|
||||
minidom.parseString(lyrics_ttml).toprettyxml()
|
||||
@@ -174,8 +193,9 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
self,
|
||||
webplayback: dict,
|
||||
lyrics: str | None = None,
|
||||
use_album_date: bool = False,
|
||||
) -> MediaTags:
|
||||
log = logger.bind(action="get_song_tags")
|
||||
|
||||
webplayback_metadata = webplayback["songList"][0]["assets"][0]["metadata"]
|
||||
|
||||
tags = MediaTags(
|
||||
@@ -197,10 +217,10 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
composer_sort=webplayback_metadata.get("sort-composer"),
|
||||
copyright=webplayback_metadata.get("copyright"),
|
||||
date=(
|
||||
await self.get_media_date(webplayback_metadata["playlistId"])
|
||||
if use_album_date
|
||||
await self.base.get_media_date(webplayback_metadata["playlistId"])
|
||||
if self.use_album_date
|
||||
else (
|
||||
self.parse_date(webplayback_metadata["releaseDate"])
|
||||
self.base.parse_date(webplayback_metadata["releaseDate"])
|
||||
if webplayback_metadata.get("releaseDate")
|
||||
else None
|
||||
)
|
||||
@@ -221,29 +241,66 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
track_total=webplayback_metadata["trackCount"],
|
||||
xid=webplayback_metadata.get("xid"),
|
||||
)
|
||||
logger.debug(f"Tags: {tags}")
|
||||
|
||||
log.debug("success", tags=tags)
|
||||
|
||||
return tags
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
song_metadata: dict | None = None,
|
||||
webplayback: dict | None = None,
|
||||
) -> StreamInfoAv | None:
|
||||
for codec in self.codec_priority:
|
||||
if codec.is_legacy():
|
||||
return await self._get_stream_info_legacy(webplayback, codec)
|
||||
else:
|
||||
return await self._get_stream_info(song_metadata, codec)
|
||||
|
||||
async def get_wrapper_m3u8(self, adam_id: str) -> str | None:
|
||||
host, port = self.base.wrapper_m3u8_ip.split(":")
|
||||
reader, writer = await asyncio.open_connection(host, port)
|
||||
|
||||
data = struct.pack("B", len(adam_id)) + adam_id.encode()
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
|
||||
response = await reader.readuntil(b"\n")
|
||||
m3u8_url = response.decode().strip()
|
||||
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
if m3u8_url:
|
||||
return m3u8_url
|
||||
|
||||
return None
|
||||
|
||||
async def _get_stream_info(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
codec: SongCodec,
|
||||
) -> StreamInfoAv | None:
|
||||
log = logger.bind(action="get_song_stream_info")
|
||||
|
||||
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),
|
||||
await self.base.apple_music_api.get_song(
|
||||
self.base.parse_catalog_media_id(song_metadata),
|
||||
)
|
||||
)["data"][0]
|
||||
|
||||
m3u8_master_url = song_metadata["attributes"]["extendedAssetUrls"].get(
|
||||
"enhancedHls"
|
||||
m3u8_master_url = (
|
||||
await self.get_wrapper_m3u8(self.base.parse_catalog_media_id(song_metadata))
|
||||
if self.base.use_wrapper
|
||||
else song_metadata["attributes"]["extendedAssetUrls"].get("enhancedHls")
|
||||
)
|
||||
if not m3u8_master_url:
|
||||
return None
|
||||
|
||||
m3u8_master_obj = m3u8.loads((await get_response(m3u8_master_url)).text)
|
||||
m3u8_master_obj = m3u8.loads(
|
||||
(await self.base.get_response(m3u8_master_url)).text
|
||||
)
|
||||
m3u8_master_data = m3u8_master_obj.data
|
||||
|
||||
if codec == SongCodec.ASK:
|
||||
@@ -255,9 +312,10 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
)
|
||||
|
||||
if playlist is None:
|
||||
log.debug("no_matching_playlist", codec=codec.value)
|
||||
return None
|
||||
|
||||
stream_info = StreamInfo()
|
||||
stream_info = StreamInfo(legacy=False)
|
||||
stream_info.stream_url = (
|
||||
f"{m3u8_master_url.rpartition('/')[0]}/{playlist['uri']}"
|
||||
)
|
||||
@@ -287,7 +345,9 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
"com.apple.streamingkeydelivery",
|
||||
)
|
||||
else:
|
||||
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
|
||||
m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(stream_info.stream_url)).text
|
||||
)
|
||||
|
||||
stream_info.widevine_pssh = self._get_drm_uri_from_m3u8_keys(
|
||||
m3u8_obj,
|
||||
@@ -306,7 +366,8 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
audio_track=stream_info,
|
||||
file_format=MediaFileFormat.MP4 if is_mp4 else MediaFileFormat.M4A,
|
||||
)
|
||||
logger.debug(f"Stream info: {stream_info_av}")
|
||||
|
||||
log.debug("success", stream_info=stream_info_av)
|
||||
|
||||
return stream_info_av
|
||||
|
||||
@@ -350,18 +411,16 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
)
|
||||
|
||||
async def _get_playlist_from_user(self, m3u8_data: dict) -> dict | None:
|
||||
choices = [
|
||||
Choice(
|
||||
name=playlist["stream_info"]["audio"],
|
||||
value=playlist,
|
||||
if self.ask_codec_function:
|
||||
playlist = self.ask_codec_function(
|
||||
[playlist for playlist in m3u8_data["playlists"]]
|
||||
)
|
||||
for playlist in m3u8_data["playlists"]
|
||||
]
|
||||
if asyncio.iscoroutine(playlist):
|
||||
playlist = await playlist
|
||||
|
||||
return await inquirer.select(
|
||||
message="Select which codec to download:",
|
||||
choices=choices,
|
||||
).execute_async()
|
||||
return playlist
|
||||
|
||||
return None
|
||||
|
||||
def _get_drm_uri_from_session_key(
|
||||
self,
|
||||
@@ -386,19 +445,23 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
return key.uri
|
||||
return None
|
||||
|
||||
async def get_stream_info_legacy(
|
||||
async def _get_stream_info_legacy(
|
||||
self,
|
||||
webplayback: dict,
|
||||
codec: SongCodec,
|
||||
) -> StreamInfoAv:
|
||||
log = logger.bind(action="get_legacy_song_stream_info")
|
||||
|
||||
flavor = "32:ctrp64" if codec == SongCodec.AAC_HE_LEGACY else "28:ctrp256"
|
||||
|
||||
stream_info = StreamInfo()
|
||||
stream_info = StreamInfo(legacy=True)
|
||||
stream_info.stream_url = next(
|
||||
i for i in webplayback["songList"][0]["assets"] if i["flavor"] == flavor
|
||||
)["URL"]
|
||||
|
||||
m3u8_obj = m3u8.loads((await get_response(stream_info.stream_url)).text)
|
||||
m3u8_obj = m3u8.loads(
|
||||
(await self.base.get_response(stream_info.stream_url)).text
|
||||
)
|
||||
stream_info.widevine_pssh = m3u8_obj.keys[0].uri
|
||||
|
||||
stream_info_av = StreamInfoAv(
|
||||
@@ -406,84 +469,75 @@ class AppleMusicSongInterface(AppleMusicInterface):
|
||||
audio_track=stream_info,
|
||||
file_format=MediaFileFormat.M4A,
|
||||
)
|
||||
logger.debug(f"Stream info legacy: {stream_info_av}")
|
||||
log.debug("success", stream_info=stream_info_av)
|
||||
|
||||
return stream_info_av
|
||||
|
||||
async def get_decryption_key_legacy(
|
||||
async def get_media(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKeyAv:
|
||||
stream_info_audio = stream_info.audio_track
|
||||
media: AppleMusicMedia,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
if not media.media_metadata:
|
||||
media.media_metadata = (
|
||||
await self.base.apple_music_api.get_song(media.media_id)
|
||||
)["data"][0]
|
||||
|
||||
try:
|
||||
cdm_session = cdm.open()
|
||||
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
|
||||
|
||||
widevine_pssh_data = WidevinePsshData()
|
||||
widevine_pssh_data.algorithm = 1
|
||||
widevine_pssh_data.key_ids.append(
|
||||
base64.b64decode(stream_info_audio.widevine_pssh.split(",")[1])
|
||||
yield media
|
||||
|
||||
if not self.base.is_media_streamable(media.media_metadata):
|
||||
raise GamdlInterfaceMediaNotStreamableError(
|
||||
media_id=media.media_id,
|
||||
)
|
||||
pssh_obj = PSSH(widevine_pssh_data.SerializeToString())
|
||||
|
||||
challenge = base64.b64encode(
|
||||
await asyncio.to_thread(
|
||||
cdm.get_license_challenge, cdm_session, pssh_obj
|
||||
if media.playlist_metadata:
|
||||
media.playlist_tags = self.base.get_playlist_tags(
|
||||
media.playlist_metadata,
|
||||
media.index,
|
||||
)
|
||||
|
||||
media.cover = await self.base.get_cover(media.media_metadata)
|
||||
|
||||
media.lyrics = await self.get_lyrics(media.media_metadata)
|
||||
|
||||
webplayback = await self.base.apple_music_api.get_webplayback(media.media_id)
|
||||
|
||||
media.tags = await self.get_tags(
|
||||
webplayback,
|
||||
media.lyrics.unsynced if media.lyrics else None,
|
||||
)
|
||||
|
||||
if not self.skip_stream_info:
|
||||
media.stream_info = await self.get_stream_info(
|
||||
media.media_metadata,
|
||||
webplayback,
|
||||
)
|
||||
if not media.stream_info:
|
||||
raise GamdlInterfaceFormatNotAvailableError(
|
||||
media_id=media.media_id,
|
||||
codec=self.codec_priority,
|
||||
)
|
||||
).decode()
|
||||
license_response = await self.apple_music_api.get_license_exchange(
|
||||
stream_info.media_id,
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
challenge,
|
||||
)
|
||||
|
||||
await asyncio.to_thread(
|
||||
cdm.parse_license, cdm_session, license_response["license"]
|
||||
)
|
||||
if (
|
||||
not self.base.use_wrapper
|
||||
and not media.stream_info.audio_track.widevine_pssh
|
||||
) or (
|
||||
self.base.use_wrapper and not media.stream_info.audio_track.fairplay_key
|
||||
):
|
||||
raise GamdlInterfaceDecryptionNotAvailableError(media_id=media.media_id)
|
||||
|
||||
decryption_key = next(
|
||||
i for i in cdm.get_keys(cdm_session) if i.type == "CONTENT"
|
||||
)
|
||||
finally:
|
||||
cdm.close(cdm_session)
|
||||
if (
|
||||
media.stream_info.audio_track.widevine_pssh
|
||||
and not self.base.use_wrapper
|
||||
) or media.stream_info.audio_track.legacy:
|
||||
media.decryption_key = DecryptionKeyAv(
|
||||
audio_track=await self.base.get_decryption_key(
|
||||
media.stream_info.audio_track.widevine_pssh,
|
||||
media.media_id,
|
||||
)
|
||||
)
|
||||
|
||||
decryption_key = DecryptionKeyAv(
|
||||
audio_track=DecryptionKey(
|
||||
kid=decryption_key.kid.hex,
|
||||
key=decryption_key.key.hex(),
|
||||
)
|
||||
)
|
||||
logger.debug(f"Decryption key legacy: {decryption_key}")
|
||||
media.partial = False
|
||||
|
||||
return decryption_key
|
||||
|
||||
async def get_decryption_key(
|
||||
self,
|
||||
stream_info: StreamInfoAv,
|
||||
cdm: Cdm,
|
||||
) -> DecryptionKeyAv:
|
||||
return DecryptionKeyAv(
|
||||
audio_track=await AppleMusicInterface.get_decryption_key(
|
||||
self,
|
||||
stream_info.audio_track.widevine_pssh,
|
||||
stream_info.media_id,
|
||||
cdm,
|
||||
)
|
||||
)
|
||||
|
||||
async def get_extra_tags(
|
||||
self,
|
||||
song_metadata: dict,
|
||||
) -> dict:
|
||||
previews = song_metadata["attributes"].get("previews", [])
|
||||
if not previews:
|
||||
return {}
|
||||
|
||||
preview_url = previews[0]["url"]
|
||||
preview_response = await get_response(preview_url)
|
||||
preview_bytes = preview_response.content
|
||||
preview_tags = dict(MP4(io.BytesIO(preview_bytes)).tags)
|
||||
|
||||
logger.debug(f"Extra tags: {preview_tags.keys()}")
|
||||
return preview_tags
|
||||
yield media
|
||||
@@ -1,5 +1,6 @@
|
||||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .enums import MediaFileFormat, MediaRating, MediaType
|
||||
|
||||
@@ -106,10 +107,10 @@ class MediaTags:
|
||||
|
||||
@dataclass
|
||||
class PlaylistTags:
|
||||
playlist_artist: str = None
|
||||
artist: str = None
|
||||
playlist_id: int = None
|
||||
playlist_title: str = None
|
||||
playlist_track: int = None
|
||||
title: str = None
|
||||
track: int = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -121,6 +122,7 @@ class StreamInfo:
|
||||
codec: str = None
|
||||
width: int = None
|
||||
height: int = None
|
||||
legacy: bool = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -141,3 +143,40 @@ class DecryptionKey:
|
||||
class DecryptionKeyAv:
|
||||
video_track: DecryptionKey = None
|
||||
audio_track: DecryptionKey = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cover:
|
||||
template_url: str = None
|
||||
file_extension: str = None
|
||||
url: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppleMusicMedia:
|
||||
media_id: str
|
||||
index: int = 0
|
||||
total: int = 0
|
||||
partial: bool = True
|
||||
media_metadata: dict | None = None
|
||||
error: BaseException | None = None
|
||||
playlist_metadata: dict | None = None
|
||||
playlist_tags: PlaylistTags | None = None
|
||||
extra_tags: dict | None = None
|
||||
cover: Cover | None = None
|
||||
lyrics: Lyrics | None = None
|
||||
tags: MediaTags | None = None
|
||||
stream_info: StreamInfoAv | None = None
|
||||
decryption_key: DecryptionKeyAv | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppleMusicUrlInfo:
|
||||
storefront: str = None
|
||||
type: str = None
|
||||
slug: str = None
|
||||
id: str = None
|
||||
sub_id: str = None
|
||||
library_storefront: str = None
|
||||
library_type: str = None
|
||||
library_id: str = None
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import structlog
|
||||
|
||||
from .base import AppleMusicBaseInterface
|
||||
from .constants import UPLOADED_VIDEO_QUALITY_RANK
|
||||
from .enums import UploadedVideoQuality
|
||||
from .exceptions import (
|
||||
GamdlInterfaceFormatNotAvailableError,
|
||||
GamdlInterfaceMediaNotStreamableError,
|
||||
)
|
||||
from .types import AppleMusicMedia, MediaFileFormat, MediaTags, StreamInfo, StreamInfoAv
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AppleMusicUploadedVideoInterface:
|
||||
def __init__(
|
||||
self,
|
||||
base: AppleMusicBaseInterface,
|
||||
quality: UploadedVideoQuality = UploadedVideoQuality.BEST,
|
||||
ask_quality_function: Callable[[dict], dict | None] | None = None,
|
||||
):
|
||||
self.base = base
|
||||
self.quality = quality
|
||||
self.ask_quality_function = ask_quality_function
|
||||
|
||||
def _get_best_stream_url(self, metadata: dict) -> str:
|
||||
best_quality = next(
|
||||
(
|
||||
quality
|
||||
for quality in UPLOADED_VIDEO_QUALITY_RANK
|
||||
if metadata["attributes"]["assetTokens"].get(quality)
|
||||
),
|
||||
None,
|
||||
)
|
||||
return metadata["attributes"]["assetTokens"][best_quality]
|
||||
|
||||
async def _get_stream_url_from_user(self, metadata: dict) -> str | None:
|
||||
if self.ask_quality_function:
|
||||
selected_quality = self.ask_quality_function(
|
||||
metadata["attributes"]["assetTokens"]
|
||||
)
|
||||
if asyncio.iscoroutine(selected_quality):
|
||||
selected_quality = await selected_quality
|
||||
return selected_quality
|
||||
|
||||
return None
|
||||
|
||||
async def _get_stream_url(
|
||||
self,
|
||||
metadata: dict,
|
||||
) -> str | None:
|
||||
if self.quality == UploadedVideoQuality.BEST:
|
||||
stream_url = self._get_best_stream_url(metadata)
|
||||
|
||||
if self.quality == UploadedVideoQuality.ASK:
|
||||
stream_url = await self._get_stream_url_from_user(metadata)
|
||||
|
||||
return stream_url
|
||||
|
||||
async def get_stream_info(
|
||||
self,
|
||||
metadata: dict,
|
||||
) -> StreamInfo | None:
|
||||
log = logger.bind(
|
||||
action="get_uploaded_video_stream_info", media_id=metadata["id"]
|
||||
)
|
||||
|
||||
stream_url = await self._get_stream_url(metadata)
|
||||
if not stream_url:
|
||||
log.debug("no_stream_url_available")
|
||||
|
||||
return None
|
||||
|
||||
stream_info = StreamInfoAv(
|
||||
file_format=MediaFileFormat.M4V,
|
||||
video_track=StreamInfo(
|
||||
stream_url=stream_url,
|
||||
),
|
||||
)
|
||||
|
||||
log.debug("success", stream_info=stream_info)
|
||||
|
||||
return stream_info
|
||||
|
||||
def get_tags(self, metadata: dict) -> MediaTags:
|
||||
log = logger.bind(action="get_uploaded_video_tags", media_id=metadata["id"])
|
||||
|
||||
attributes = metadata["attributes"]
|
||||
upload_date = attributes.get("uploadDate")
|
||||
|
||||
tags = MediaTags(
|
||||
artist=attributes.get("artistName"),
|
||||
date=self.base.parse_date(upload_date) if upload_date else None,
|
||||
title=attributes.get("name"),
|
||||
title_id=int(metadata["id"]),
|
||||
storefront=self.base.itunes_api.storefront_id,
|
||||
)
|
||||
|
||||
log.debug("success", tags=tags)
|
||||
|
||||
return tags
|
||||
|
||||
async def get_media(
|
||||
self,
|
||||
media: AppleMusicMedia,
|
||||
) -> AsyncGenerator[AppleMusicMedia, None]:
|
||||
if not media.media_metadata:
|
||||
media.media_metadata = (
|
||||
await self.base.apple_music_api.get_uploaded_video(media.media_id)
|
||||
)["data"][0]
|
||||
|
||||
media.media_id = self.base.parse_catalog_media_id(media.media_metadata)
|
||||
|
||||
yield media
|
||||
|
||||
if not self.base.is_media_streamable(media.media_metadata):
|
||||
raise GamdlInterfaceMediaNotStreamableError(media.media_id)
|
||||
|
||||
media.cover = await self.base.get_cover(media.media_metadata)
|
||||
|
||||
media.stream_info = await self.get_stream_info(media.media_metadata)
|
||||
if not media.stream_info:
|
||||
raise GamdlInterfaceFormatNotAvailableError(media.media_id)
|
||||
|
||||
media.tags = self.get_tags(media.media_metadata)
|
||||
|
||||
media.partial = False
|
||||
|
||||
yield media
|
||||
@@ -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=="""
|
||||
@@ -1,35 +1,8 @@
|
||||
import asyncio
|
||||
import json
|
||||
import string
|
||||
import subprocess
|
||||
import typing
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def raise_for_status(httpx_response: httpx.Response, valid_responses: set[int] = {200}):
|
||||
if httpx_response.status_code not in valid_responses:
|
||||
raise httpx._exceptions.HTTPError(
|
||||
f"HTTP error {httpx_response.status_code}: {httpx_response.text}"
|
||||
)
|
||||
|
||||
|
||||
def safe_json(httpx_response: httpx.Response) -> dict | 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:
|
||||
@@ -66,22 +39,6 @@ async def safe_gather(
|
||||
)
|
||||
|
||||
|
||||
async def sequential_gather(
|
||||
*tasks: typing.Awaitable[typing.Any],
|
||||
interval: float = 0.5,
|
||||
) -> list[typing.Any]:
|
||||
results = []
|
||||
for i, task in enumerate(tasks):
|
||||
try:
|
||||
result = await task
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
results.append(e)
|
||||
if interval > 0 and i < len(tasks) - 1:
|
||||
await asyncio.sleep(interval)
|
||||
return results
|
||||
|
||||
|
||||
class CustomStringFormatter(string.Formatter):
|
||||
def format_field(self, value: typing.Any, format_spec: str) -> str:
|
||||
if isinstance(value, tuple) and len(value) == 2:
|
||||
|
||||
+9
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "gamdl"
|
||||
version = "2.8.7"
|
||||
version = "3.2"
|
||||
description = "A command-line app for downloading Apple Music songs, music videos and post videos."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -11,11 +11,13 @@ dependencies = [
|
||||
"colorama>=0.4.6",
|
||||
"dataclass-click>=1.0.4",
|
||||
"httpx>=0.28.1",
|
||||
"httpx-retries>=0.4.6",
|
||||
"inquirerpy>=0.3.4",
|
||||
"m3u8>=6.0.0",
|
||||
"mutagen>=1.47.0",
|
||||
"pillow>=12.0.0",
|
||||
"pywidevine>=1.8.0",
|
||||
"structlog>=25.5.0",
|
||||
"yt-dlp>=2025.10.22",
|
||||
]
|
||||
|
||||
@@ -24,3 +26,9 @@ Repository = "https://github.com/glomatico/gamdl"
|
||||
|
||||
[project.scripts]
|
||||
gamdl = "gamdl.cli.cli:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=9.0.3",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
]
|
||||
|
||||
@@ -29,6 +29,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069, upload-time = "2025-03-16T17:25:35.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-asyncio-runner"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backports-datetime-fromisoformat"
|
||||
version = "2.0.3"
|
||||
@@ -214,7 +223,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gamdl"
|
||||
version = "2.8.7"
|
||||
version = "3.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "async-lru" },
|
||||
@@ -222,14 +231,22 @@ dependencies = [
|
||||
{ name = "colorama" },
|
||||
{ name = "dataclass-click" },
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx-retries" },
|
||||
{ name = "inquirerpy" },
|
||||
{ name = "m3u8" },
|
||||
{ name = "mutagen" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pywidevine" },
|
||||
{ name = "structlog" },
|
||||
{ name = "yt-dlp" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "async-lru", specifier = ">=2.0.5" },
|
||||
@@ -237,14 +254,22 @@ requires-dist = [
|
||||
{ name = "colorama", specifier = ">=0.4.6" },
|
||||
{ name = "dataclass-click", specifier = ">=1.0.4" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "httpx-retries", specifier = ">=0.4.6" },
|
||||
{ name = "inquirerpy", specifier = ">=0.3.4" },
|
||||
{ name = "m3u8", specifier = ">=6.0.0" },
|
||||
{ name = "mutagen", specifier = ">=1.47.0" },
|
||||
{ name = "pillow", specifier = ">=12.0.0" },
|
||||
{ name = "pywidevine", specifier = ">=1.8.0" },
|
||||
{ name = "structlog", specifier = ">=25.5.0" },
|
||||
{ name = "yt-dlp", specifier = ">=2025.10.22" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -282,6 +307,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-retries"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/13/5eac2df576c02280f79e4639a6d4c93a25cfe94458275f5aa55f5e6c8ea0/httpx_retries-0.4.6.tar.gz", hash = "sha256:a076d8a5ede5d5794e9c241da17b15b393b482129ddd2fdf1fa56a3fa1f28a7f", size = 13466, upload-time = "2026-02-17T16:16:05.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/97/63f56da4400034adde22adfe7524635dba068f17d6858f92ecd96f55b53e/httpx_retries-0.4.6-py3-none-any.whl", hash = "sha256:d66d912173b844e065ffb109345a453b922f4c2cd9c9e11139304cb33e7a1ee1", size = 8490, upload-time = "2026-02-17T16:16:04.137Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@@ -291,6 +328,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inquirerpy"
|
||||
version = "0.3.4"
|
||||
@@ -325,6 +371,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pfzy"
|
||||
version = "0.3.4"
|
||||
@@ -432,6 +487,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.52"
|
||||
@@ -493,6 +557,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pymp4"
|
||||
version = "1.4.0"
|
||||
@@ -505,6 +578,38 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a2/27fea39af627c0ce5dbf6108bf969ea8f5fc9376d29f11282a80e3426f1d/pymp4-1.4.0-py3-none-any.whl", hash = "sha256:3401666c1e2a97ac94dffb18c5a5dcbd46d0a436da5272d378a6f9f6506dd12d", size = 14832, upload-time = "2023-05-07T15:01:32.293Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywidevine"
|
||||
version = "1.8.0"
|
||||
@@ -611,6 +716,72 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "structlog"
|
||||
version = "25.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
||||
Reference in New Issue
Block a user